Compare commits

..

7 Commits

Author SHA1 Message Date
daniel156161 8dece7800f feat: group multi-browser output by source
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
2026-06-18 00:52:04 +02:00
daniel156161 479a0f1964 feat: improve remote browser tree routing
Testing / remote-protocol-compat (0.9.3) (push) Successful in 43s
Testing / test (push) Successful in 1m1s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Build & Publish Package / publish (push) Successful in 58s
Package Extension / package-extension (push) Successful in 1m15s
- Allow remote host aliases passed via --browser to fan out for read-only
  multi-browser SDK paths while preserving strict routing for mutating commands.
- Add remote host grouping and scoped profile labels to tabs tree output so
  global views avoid repeated host prefixes.
- Carry browser family metadata through remote targets, tabs, and groups and
  style tree browser labels by family.
- Split CLI rendering helpers into a typed rendering package with dedicated
  common, label, tabs-tree, and windows-tree modules.
- Bump browser-cli and extension versions to 0.15.5.
- Cover the new routing and rendering behavior with unit and CLI tests.
2026-06-18 00:12:17 +02:00
daniel156161 371b794170 chore: prepare verified CRX uploads and release 0.15.4
Testing / remote-protocol-compat (0.9.5) (push) Successful in 36s
Package Extension / package-extension (push) Successful in 33s
Build & Publish Package / publish (push) Successful in 31s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 32s
Testing / test (push) Successful in 36s
- Add helper scripts for Chrome Web Store verified CRX uploads using a dedicated RSA upload key protected by GPG.
- Document the verified upload packaging flow and ignore local signing secrets.
- Add npm packaging entry point for signed webstore CRX artifacts.
- Chunk large SDK tab close batches to avoid native-host response timeouts.
- Bump project and extension versions to 0.15.4 with matching tests.
2026-06-17 16:54:20 +02:00
daniel156161 0ac652beee ci: publish gitea package as browser-cli
Testing / test (push) Successful in 1m13s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 57s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 53s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m9s
2026-06-15 01:39:12 +02:00
daniel156161 7cb2a8b618 refactor: modularize auth transport and markdown
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s
- Split auth into focused package modules for agent keys, file keys,
  signing, and post-quantum transport helpers while keeping the public
  browser_cli.auth import surface intact.
- Move transport encoding internals into a package with separate codec and
  binary-hoisting helpers, preserving browser_cli.transport compatibility.
- Extract remote TCP auth/socket helpers and serve challenge setup out of the
  runtime paths to make connection handling easier to reason about.
- Move the extension markdown extractor into a dedicated content/markdown
  folder with separate root selection, code normalization, renderer, and utils.
- Centralize CLI Rich rendering helpers for tab/window tree and table output,
  and add rendering tests for the shared builders.
- Remove local typing ignores in SDK/decorator/script plumbing and bump the
  package and extension version to 0.15.3.
2026-06-15 01:23:57 +02:00
daniel156161 0b43408a8d feat(cli): improve tab and window tree rendering
- Add shared rendering helpers for width-aware tree labels, truncation, and no-wrap Rich text.
- Preserve tab index and group window metadata through the extension and SDK factories.
- Render tab trees in browser/window/index order with grouped tab details and optional shortened URLs.
- Reuse the tab tree labels in window trees to keep output compact and consistent.
- Cover legacy missing-index responses, grouped/collapsed tabs, URL display, and rendering helpers with tests.
2026-06-15 01:04:02 +02:00
daniel156161 657b1b0923 test: make Firefox install output assertion wrap-safe
Testing / remote-protocol-compat (0.9.5) (push) Successful in 39s
Testing / test (push) Successful in 1m0s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 2m28s
- Normalize Rich CLI output before asserting the packaged Firefox manifest path.
- Replace split substring checks with the full expected manifest path.
- Keep the warning assertion stable when terminal wrapping inserts line breaks.
2026-06-14 22:57:07 +02:00
64 changed files with 3046 additions and 1657 deletions
+25 -2
View File
@@ -17,8 +17,31 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
- name: Build package - name: Build Gitea package
run: uv build run: |
# Keep the public/PyPI distribution as real-browser-cli in the repo,
# but publish the private Gitea package under browser-cli.
python - <<'PY'
from pathlib import Path
replacements = {
Path("pyproject.toml"): (
'name = "real-browser-cli"',
'name = "browser-cli"',
),
Path("browser_cli/constants.py"): (
'PYPI_PACKAGE_NAME = "real-browser-cli"',
'PYPI_PACKAGE_NAME = "browser-cli"',
),
}
for path, (old, new) in replacements.items():
text = path.read_text()
if old not in text:
raise SystemExit(f"expected text not found in {path}: {old}")
path.write_text(text.replace(old, new, 1))
PY
uv build
- name: Publish to Gitea - name: Publish to Gitea
run: | run: |
+5
View File
@@ -5,6 +5,11 @@ extension/test-dist/
node_modules/ node_modules/
dist/ dist/
# Local secrets / signing keys
secrets/
*.pem
*.pem.gpg
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
+71 -58
View File
@@ -1,20 +1,21 @@
# browser-cli # browser-cli
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands. Control your real, running browser from the terminal, Python SDK, or a trusted remote client — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
--- ---
## What it does ## What it does
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for. You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel. It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel, or through the optional authenticated TCP remote bridge.
--- ---
## How it works ## How it works
``` ```
terminal / python script terminal / python script / remote client
│ Local IPC (Unix socket on Linux/macOS, named pipe on Windows) │ Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
│ or TCP remote bridge (Ed25519 auth, optional compression)
Native Messaging Host (Python process, launched by the browser) Native Messaging Host (Python process, launched by the browser)
@@ -33,7 +34,7 @@ terminal / python script
4. CLI commands connect to that socket, send a JSON command, and wait for the result. 4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI. 5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
No server needs to be running beforehand. The browser manages the native host's lifecycle. No local server needs to be running beforehand. The browser manages the native host's lifecycle. For cross-machine control, `browser-cli serve` starts an explicit TCP listener protected by Ed25519 public-key authentication unless you opt out with `--no-auth`.
**Message format** **Message format**
@@ -53,7 +54,7 @@ Every response:
**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox **Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox
### Install with uv ### Install with uv
Once published on PyPI, install the CLI as a uv tool: Install the CLI from PyPI as a uv tool:
```sh ```sh
uv tool install real-browser-cli uv tool install real-browser-cli
@@ -100,62 +101,42 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
```text ```text
browser-cli/ browser-cli/
├── browser_cli/ ├── browser_cli/
│ ├── __init__.py # Python SDK BrowserCLI class and SDK entry point │ ├── __init__.py # Public sync SDK: BrowserCLI and namespace wiring
│ ├── cli.py # Click CLI entry point │ ├── async_sdk.py # AsyncBrowserCLI
│ ├── client/ # Client-side command routing used by CLI and SDK │ ├── cli.py # Click root command and native-host entry point
│ ├── core.py # send_command and remote command routing │ ├── client/ # send_command path, local/remote routing, message helpers
│ ├── targets.py # Browser target discovery and socket resolution │ ├── sdk/ # SDK namespaces: nav, tabs, groups, windows, dom, session, ...
│ ├── auth.py # Remote auth fields and key lookup │ ├── commands/ # CLI presentation layer over the SDK namespaces
│ └── messages.py # Request/response helpers ├── native/ # Browser-launched Native Messaging host + local IPC server
│ ├── models.py # Tab and Group helper models │ ├── remote/ # TCP remote client transport and saved endpoint registry
│ ├── native/ # Native messaging host internals │ ├── serve/ # Authenticated TCP server runtime
│ ├── host.py # Browser-launched native host entry point │ ├── transport/ # JSON/msgpack response encoding and compression helpers
│ ├── local_server.py # Local CLI IPC server │ ├── markdown/ # HTML-to-Markdown extraction helpers
│ └── protocol.py # Chrome Native Messaging framing ├── auth/ # Ed25519 keys, signing, SSH-agent/YubiKey helpers, PQ KEX
── remote/ # Client-side remote browser support ── models.py # Tab, Group, BrowserCounts dataclasses
│ │ ├── transport.py # TCP/TLS remote transport
│ │ └── registry.py # Saved remote endpoints/keys
│ └── commands/
│ ├── navigate.py # nav open/reload/back/forward/focus
│ ├── search.py # search engine shortcuts
│ ├── tabs.py # tab management
│ ├── groups.py # tab group management
│ ├── windows.py # window management
│ ├── dom.py # DOM querying and interaction
│ ├── extract.py # content extraction
│ └── session.py # session save/load
├── extension/ ├── extension/
│ ├── manifest.json # MV3 extension manifest │ ├── manifest.json # Chromium MV3 manifest
── content.js # Content-script helpers ── src/ # TypeScript WebExtension source
└── src/ # TypeScript source split by command area ├── index.ts # Background/service-worker bundle entry
│ ├── index.ts # Builds generated extension/background.js │ ├── content-dispatch.ts
── content/ # Builds generated extension/content-dispatch.js ── commands/ # Browser-side command implementations
├── examples/ │ ├── content/ # DOM/extract/Markdown logic injected into pages
├── demo.py # Python SDK walkthrough └── core/ # Shared extension helpers
│ └── demo.sh # Bash CLI walkthrough ├── examples/ # Python and shell walkthroughs
├── tests/ ├── scripts/ # Packaging and release helper scripts
│ ├── conftest.py # shared pytest fixtures ├── tests/ # pytest suite
│ ├── test_api.py ├── package.json # Extension build/test/package scripts
│ ├── test_cli.py ├── pyproject.toml # Python package metadata
│ ├── test_dom.py └── uv.lock # locked Python dependencies
│ ├── test_extract.py
│ ├── test_groups.py
│ ├── test_nav.py
│ ├── test_session.py
│ ├── test_tabs.py
│ └── test_windows.py
├── com.browsercli.host.json # native messaging manifest template
├── pyproject.toml # package metadata and CLI entry point
└── uv.lock # locked dependencies for uv
``` ```
--- ---
## CLI reference ## CLI reference
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`. During source development, commands are usually run as `uv run browser-cli [--browser ALIAS] <command>`. After tool installation, use `browser-cli ...` directly. Add `--remote HOST[:PORT]` and optionally `--key PATH` to target a browser exposed by `browser-cli serve`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically. If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. When local and saved remote browsers are mixed, tables group rows by source (`local` or the remote endpoint) and indent the browser profile below that group. You can inspect active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
@@ -315,6 +296,27 @@ browser-cli completion zsh # print setup instructions
browser-cli completion zsh --script # output raw completion script browser-cli completion zsh --script # output raw completion script
``` ```
### Remote control, auth, and gateways
```sh
# On the machine with the browser
browser-cli auth keygen --output ~/.config/browser-cli/client.key
PUBKEY=$(browser-cli auth show --key ~/.config/browser-cli/client.key | tail -n1)
browser-cli auth trust "$PUBKEY"
browser-cli serve --host 0.0.0.0 --port 8765 --authorized-keys ~/.config/browser-cli/authorized_keys
# From another machine
browser-cli --remote browser-host.example:8765 --key ~/.config/browser-cli/client.key tabs list
browser-cli remote trust browser-host.example:8765 ~/.config/browser-cli/client.key
browser-cli --remote browser-host.example:8765 clients
# Local HTTP JSON gateway for small integrations
browser-cli serve-http --port 8766
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8766/tabs
```
Remote auth uses Ed25519 challenge/response. `--remote` domains default to port 443; explicit `host:port` endpoints are also supported. Saved remote endpoints participate in aggregate list/count commands, where output is grouped by endpoint.
--- ---
## Python SDK ## Python SDK
@@ -325,7 +327,7 @@ from browser_cli import AsyncBrowserCLI, BrowserCLI
b = BrowserCLI() b = BrowserCLI()
``` ```
Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport. Commands are grouped into namespaces on the client (`b.tabs`, `b.dom`, `b.session`, ...). Each sync call blocks until the browser responds and returns the data directly as a Python object. Create `BrowserCLI(remote="host:8765", key="client.key")` to target a remote server. For asyncio programs, `AsyncBrowserCLI` exposes the same namespaces as native awaitable methods over async Unix/TCP transport.
```python ```python
# Navigation ── b.nav # Navigation ── b.nav
@@ -480,6 +482,7 @@ counts = b.tabs.count()
if isinstance(counts, BrowserCounts): if isinstance(counts, BrowserCounts):
print(counts.total) print(counts.total)
print(counts.by_browser) print(counts.by_browser)
print(counts.browser_groups) # e.g. {"local:work": "local", "remote:work": "remote"}
``` ```
--- ---
@@ -515,12 +518,22 @@ The extension source lives in `extension/src/`. `extension/background.js` and `e
Packaging: Packaging:
```bash ```bash
npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID just publish # build to /tmp/dist-browser-cli and publish with .env credentials
npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key npm run package:extension # testing/unpacked zip, keeps manifest.key for stable Chromium native-messaging ID
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions npm run package:extension:webstore # Chrome Web Store zip, strips manifest.key
npm run package:extension:webstore:verified # Chrome Web Store CRX signed for verified uploads
npm run package:extension:firefox # Firefox zip, strips manifest.key and Firefox-incompatible permissions
``` ```
Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For Firefox, use the `*-firefox-*` zip. Chrome Web Store rejects `manifest.key`, so upload the `*-webstore-*` zip from `dist/`. For verified CRX uploads, create a dedicated RSA upload key once and protect it with your GPG key:
```bash
scripts/setup_verified_crx_key.sh --recipient '<your GPG key id or email>'
# Add the generated public key in Chrome Developer Dashboard -> Package -> Verified uploads.
npm run package:extension:webstore:verified
```
The verified-upload private key is not a GPG key; Chrome requires an RSA CRX signing key. GPG is used here to encrypt that RSA private key at rest. The signed `*.crx` from `dist/` is the upload artifact after verified uploads are enabled. For Firefox, use the `*-firefox-*` zip.
For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons. For Firefox temporary testing via `about:debugging#/runtime/this-firefox`, run `npm run package:extension:firefox` first and load `dist/extension-package-firefox/manifest.json`. Do **not** load `extension/manifest.json` directly: it is the Chromium MV3 manifest and Firefox currently rejects `background.service_worker` for temporary add-ons.
+1 -1
View File
@@ -36,7 +36,7 @@ Commands are grouped into namespaces on the client:
""" """
from collections.abc import Callable from collections.abc import Callable
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, send_command_async from browser_cli.client import active_browser_targets, remote_browser_targets, remote_targets_for_alias, send_command, send_command_async
from browser_cli.errors import BrowserNotConnected from browser_cli.errors import BrowserNotConnected
from browser_cli.models import BrowserCounts, Group, Tab from browser_cli.models import BrowserCounts, Group, Tab
from browser_cli.sdk import ( from browser_cli.sdk import (
+29 -7
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
from collections.abc import Callable from collections.abc import Callable
from typing import TypeVar from typing import TypeVar, cast
from browser_cli.models import Group, Tab from browser_cli.models import Group, Tab
from browser_cli.sdk import NAMESPACE_NAMES from browser_cli.sdk import NAMESPACE_NAMES
@@ -74,7 +74,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally: finally:
if cleanup is not None: if cleanup is not None:
await self._maybe_await(cleanup(value)) await self._maybe_await(cleanup(value))
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator(func) if func is not None else decorator return decorator(func) if func is not None else decorator
def new_tab( def new_tab(
@@ -117,7 +117,7 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
finally: finally:
if previous: if previous:
await self._c.perf.set_profile(previous) await self._c.perf.set_profile(previous)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
def retry( def retry(
@@ -142,8 +142,8 @@ class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
raise raise
if delay > 0: if delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
class AsyncBrowserCLI: class AsyncBrowserCLI:
@@ -220,18 +220,40 @@ class AsyncBrowserCLI:
async def clients(self) -> list[dict]: async def clients(self) -> list[dict]:
return await self._cmd("clients.list", {}) return await self._cmd("clients.list", {})
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab: def tab_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Tab:
return self._sync.tab_from( return self._sync.tab_from(
data, data,
browser_profile=browser_profile, browser_profile=browser_profile,
browser_name=browser_name, browser_name=browser_name,
browser_remote=browser_remote, browser_remote=browser_remote,
browser_type=browser_type,
browser_group=browser_group,
) )
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group: def group_from(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
browser_type: str | None = None,
browser_group: str | None = None,
) -> Group:
return self._sync.group_from( return self._sync.group_from(
data, data,
browser_profile=browser_profile, browser_profile=browser_profile,
browser_name=browser_name, browser_name=browser_name,
browser_remote=browser_remote, browser_remote=browser_remote,
browser_type=browser_type,
browser_group=browser_group,
) )
-263
View File
@@ -1,263 +0,0 @@
"""Ed25519 keypair management, ML-KEM key exchange, and auth helpers."""
import hashlib
import json
import os
import secrets
import socket
import struct
from dataclasses import dataclass
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
from browser_cli.constants import (
DEFAULT_AUTHORIZED_KEYS_PATH,
DEFAULT_KEY_PATH,
PQ_KEX_ALG,
PQ_TRANSPORT_ALG,
SSH_AGENT_IDENTITIES_ANSWER,
SSH_AGENT_SIGN_RESPONSE,
SSH_AGENTC_REQUEST_IDENTITIES,
SSH_AGENTC_SIGN_REQUEST,
)
def _pack_str(s: bytes) -> bytes:
return struct.pack(">I", len(s)) + s
def _unpack_str(data: bytes, off: int) -> tuple[bytes, int]:
n = struct.unpack_from(">I", data, off)[0]
return data[off + 4 : off + 4 + n], off + 4 + n
def _agent_roundtrip(msg: bytes) -> bytes:
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
n = struct.unpack(">I", raw_len)[0]
resp = b""
while len(resp) < n:
chunk = sock.recv(n - len(resp))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
resp += chunk
return resp
# ── AgentKey ───────────────────────────────────────────────────────────────────
@dataclass
class AgentKey:
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, off = _unpack_str(self.blob, 0)
key_bytes, _ = _unpack_str(self.blob, off)
return key_bytes
# ── Agent helpers ──────────────────────────────────────────────────────────────
def agent_list_keys() -> list[AgentKey]:
"""Return all Ed25519 keys currently held by the SSH agent."""
resp = _agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
if resp[0] != SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {resp[0]}")
n_keys = struct.unpack_from(">I", resp, 1)[0]
keys: list[AgentKey] = []
off = 5
for _ in range(n_keys):
blob, off = _unpack_str(resp, off)
comment, off = _unpack_str(resp, off)
algo, _ = _unpack_str(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
def agent_find_key(selector: str | None = None) -> AgentKey | None:
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([SSH_AGENTC_SIGN_REQUEST])
+ _pack_str(key.blob)
+ _pack_str(data)
+ struct.pack(">I", 0)
)
resp = _agent_roundtrip(msg)
if resp[0] != SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {resp[0]})")
sig_blob, _ = _unpack_str(resp, 1)
_algo, soff = _unpack_str(sig_blob, 0)
raw_sig, _ = _unpack_str(sig_blob, soff)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
# ── File-based key helpers ─────────────────────────────────────────────────────
def generate_keypair() -> tuple[bytes, str]:
"""Return (private_key_pem_bytes, public_key_hex)."""
priv = Ed25519PrivateKey.generate()
pem = priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
pub_hex = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, pub_hex
def load_private_key(path: Path) -> Ed25519PrivateKey:
return load_pem_private_key(path.read_bytes(), password=None)
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
# ── Canonical payload + sign/verify ───────────────────────────────────────────
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = _auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), _auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
priv = mlkem.MLKEM768PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
return priv, pub
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
pub = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = pub.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pk for pk, _ in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pk for pk, _ in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
return True
+67
View File
@@ -0,0 +1,67 @@
"""Public auth API for browser-cli.
Implementation lives in focused modules:
- ``auth.agent``: SSH-agent/YubiKey helpers
- ``auth.keys``: file keys and authorized_keys management
- ``auth.signing``: canonical payload signing/verification
- ``auth.pq``: ML-KEM KEX and encrypted transport helpers
"""
from browser_cli.auth.agent import (
AgentKey,
agent_find_key,
agent_list_keys,
agent_roundtrip as _agent_roundtrip,
agent_sign_raw,
pack_ssh_string as _pack_str,
unpack_ssh_string as _unpack_str,
)
from browser_cli.auth.keys import (
add_authorized_key,
generate_keypair,
load_authorized_keys,
load_authorized_keys_with_names,
load_private_key,
public_key_hex,
)
from browser_cli.auth.pq import (
new_nonce,
pq_decrypt,
pq_encrypt,
pq_kex_client_encapsulate,
pq_kex_server_decapsulate,
pq_kex_server_keypair,
pq_transport_key as _pq_transport_key,
)
from browser_cli.auth.signing import (
auth_message as _auth_message,
canonical_payload,
sign,
verify,
)
from browser_cli.constants import DEFAULT_AUTHORIZED_KEYS_PATH, DEFAULT_KEY_PATH, PQ_KEX_ALG, PQ_TRANSPORT_ALG
__all__ = [
"AgentKey",
"DEFAULT_AUTHORIZED_KEYS_PATH",
"DEFAULT_KEY_PATH",
"PQ_KEX_ALG",
"PQ_TRANSPORT_ALG",
"add_authorized_key",
"agent_find_key",
"agent_list_keys",
"agent_sign_raw",
"canonical_payload",
"generate_keypair",
"load_authorized_keys",
"load_authorized_keys_with_names",
"load_private_key",
"new_nonce",
"pq_decrypt",
"pq_encrypt",
"pq_kex_client_encapsulate",
"pq_kex_server_decapsulate",
"pq_kex_server_keypair",
"public_key_hex",
"sign",
"verify",
]
+103
View File
@@ -0,0 +1,103 @@
"""SSH-agent backed Ed25519 key helpers."""
from __future__ import annotations
import os
import socket
import struct
from dataclasses import dataclass
from browser_cli.constants import (
SSH_AGENT_IDENTITIES_ANSWER,
SSH_AGENT_SIGN_RESPONSE,
SSH_AGENTC_REQUEST_IDENTITIES,
SSH_AGENTC_SIGN_REQUEST,
)
def pack_ssh_string(value: bytes) -> bytes:
return struct.pack(">I", len(value)) + value
def unpack_ssh_string(data: bytes, offset: int) -> tuple[bytes, int]:
length = struct.unpack_from(">I", data, offset)[0]
return data[offset + 4 : offset + 4 + length], offset + 4 + length
def agent_roundtrip(msg: bytes) -> bytes:
sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
raise RuntimeError("SSH_AUTH_SOCK not set — is gpg-agent / ssh-agent running?")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(10)
sock.connect(sock_path)
sock.sendall(struct.pack(">I", len(msg)) + msg)
raw_len = b""
while len(raw_len) < 4:
chunk = sock.recv(4 - len(raw_len))
if not chunk:
raise RuntimeError("SSH agent closed connection")
raw_len += chunk
length = struct.unpack(">I", raw_len)[0]
response = b""
while len(response) < length:
chunk = sock.recv(length - len(response))
if not chunk:
raise RuntimeError("SSH agent closed connection mid-response")
response += chunk
return response
@dataclass
class AgentKey:
"""Ed25519 key backed by an SSH agent (YubiKey, TPM, ssh-agent, gpg-agent …)."""
blob: bytes
comment: str
@property
def pubkey_bytes(self) -> bytes:
_algo, offset = unpack_ssh_string(self.blob, 0)
key_bytes, _ = unpack_ssh_string(self.blob, offset)
return key_bytes
def agent_list_keys() -> list[AgentKey]:
"""Return all Ed25519 keys currently held by the SSH agent."""
response = agent_roundtrip(bytes([SSH_AGENTC_REQUEST_IDENTITIES]))
if response[0] != SSH_AGENT_IDENTITIES_ANSWER:
raise RuntimeError(f"Unexpected agent response: {response[0]}")
key_count = struct.unpack_from(">I", response, 1)[0]
keys: list[AgentKey] = []
offset = 5
for _ in range(key_count):
blob, offset = unpack_ssh_string(response, offset)
comment, offset = unpack_ssh_string(response, offset)
algo, _ = unpack_ssh_string(blob, 0)
if algo == b"ssh-ed25519":
keys.append(AgentKey(blob=blob, comment=comment.decode("utf-8", errors="replace")))
return keys
def agent_find_key(selector: str | None = None) -> AgentKey | None:
"""Return the first agent Ed25519 key whose comment contains selector (or any if None)."""
try:
keys = agent_list_keys()
except Exception:
return None
for key in keys:
if key.comment == "(none)":
continue
if selector is None or selector in key.comment:
return key
return None
def agent_sign_raw(key: AgentKey, data: bytes) -> bytes:
"""Ask the SSH agent to sign data and return the raw 64-byte Ed25519 signature."""
msg = (
bytes([SSH_AGENTC_SIGN_REQUEST])
+ pack_ssh_string(key.blob)
+ pack_ssh_string(data)
+ struct.pack(">I", 0)
)
response = agent_roundtrip(msg)
if response[0] != SSH_AGENT_SIGN_RESPONSE:
raise RuntimeError(f"SSH agent refused to sign (response code {response[0]})")
sig_blob, _ = unpack_ssh_string(response, 1)
_algo, sig_offset = unpack_ssh_string(sig_blob, 0)
raw_sig, _ = unpack_ssh_string(sig_blob, sig_offset)
if len(raw_sig) != 64:
raise RuntimeError(f"Unexpected signature length {len(raw_sig)}")
return raw_sig
+59
View File
@@ -0,0 +1,59 @@
"""File-based Ed25519 keys and authorized_keys helpers."""
from __future__ import annotations
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
from browser_cli.auth.agent import AgentKey
def generate_keypair() -> tuple[bytes, str]:
"""Return (private_key_pem_bytes, public_key_hex)."""
private_key = Ed25519PrivateKey.generate()
pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
public_hex = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
return pem, public_hex
def load_private_key(path: Path) -> Ed25519PrivateKey:
return load_pem_private_key(path.read_bytes(), password=None)
def public_key_hex(key: Ed25519PrivateKey | AgentKey) -> str:
if isinstance(key, AgentKey):
return key.pubkey_bytes.hex()
return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
def load_authorized_keys_with_names(path: Path) -> list[tuple[str, str]]:
"""Return list of (pubkey_hex, name) pairs. Name is empty string if not set."""
if not path.exists():
return []
result = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
pubkey = parts[0]
name = parts[1].strip() if len(parts) > 1 else ""
result.append((pubkey, name))
return result
def load_authorized_keys(path: Path) -> list[str]:
return [pubkey for pubkey, _name in load_authorized_keys_with_names(path)]
def add_authorized_key(path: Path, pub_hex: str, name: str = "") -> bool:
"""Append pub_hex to authorized_keys. Returns False if already present."""
path.parent.mkdir(parents=True, exist_ok=True)
existing = {pubkey for pubkey, _name in load_authorized_keys_with_names(path)}
if pub_hex in existing:
return False
line = (f"{pub_hex} {name}".rstrip()) + "\n"
with open(path, "a", encoding="utf-8") as file:
file.write(line)
return True
+65
View File
@@ -0,0 +1,65 @@
"""Post-quantum ML-KEM key exchange and app-layer transport encryption."""
from __future__ import annotations
import secrets
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from browser_cli.constants import PQ_TRANSPORT_ALG
def pq_kex_server_keypair():
"""Return an ephemeral ML-KEM-768 private key and raw public key bytes.
Returns ``None`` when the installed cryptography/OpenSSL backend does not
support ML-KEM yet. The serve/client protocol treats this as graceful
downgrade instead of breaking local installs on older OpenSSL builds.
"""
try:
from cryptography.hazmat.primitives.asymmetric import mlkem
private_key = mlkem.MLKEM768PrivateKey.generate()
public_key = private_key.public_key().public_bytes_raw()
return private_key, public_key
except Exception:
return None
def pq_kex_client_encapsulate(public_key_hex: str) -> tuple[str, bytes]:
"""Encapsulate to a server ML-KEM public key. Returns (ciphertext_hex, secret)."""
from cryptography.hazmat.primitives.asymmetric import mlkem
public_key = mlkem.MLKEM768PublicKey.from_public_bytes(bytes.fromhex(public_key_hex))
shared_secret, ciphertext = public_key.encapsulate()
return ciphertext.hex(), shared_secret
def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
"""Decapsulate a client ML-KEM ciphertext and return the shared secret."""
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
+42
View File
@@ -0,0 +1,42 @@
"""Canonical browser-cli auth payload signing and verification."""
from __future__ import annotations
import hashlib
import json
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from browser_cli.auth.agent import AgentKey, agent_sign_raw
def canonical_payload(msg: dict) -> bytes:
"""Deterministic JSON encoding of msg without auth protocol fields."""
return json.dumps(
{key: value for key, value in msg.items() if key not in {"pubkey", "sig", "pq_kex"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def auth_message(nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Bytes signed for auth; optionally binds a post-quantum KEX secret."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
if pq_shared_secret is not None:
data += hashlib.sha256(b"browser-cli ml-kem-768 v1" + pq_shared_secret).digest()
return data
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict, pq_shared_secret: bytes | None = None) -> bytes:
"""Sign nonce + payload hash, optionally bound to an ML-KEM shared secret."""
data = auth_message(nonce, msg, pq_shared_secret)
if isinstance(key, AgentKey):
return agent_sign_raw(key, data)
return key.sign(data)
def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret: bytes | None = None) -> bool:
"""Return True if sig_hex is a valid signature over the canonical payload/auth secret."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
pub_key.verify(bytes.fromhex(sig_hex), auth_message(nonce, msg, pq_shared_secret))
return True
except (InvalidSignature, ValueError):
return False
+2
View File
@@ -10,6 +10,7 @@ from browser_cli.client.core import (
remote_browser_targets, remote_browser_targets,
remote_browser_targets_async, remote_browser_targets_async,
remote_target_for_alias, remote_target_for_alias,
remote_targets_for_alias,
send_command, send_command,
send_command_async, send_command_async,
) )
@@ -42,6 +43,7 @@ __all__ = [
"remote_browser_targets", "remote_browser_targets",
"remote_browser_targets_async", "remote_browser_targets_async",
"remote_target_for_alias", "remote_target_for_alias",
"remote_targets_for_alias",
"send_command", "send_command",
"send_command_async", "send_command_async",
] ]
+27 -12
View File
@@ -25,12 +25,16 @@ def _remote_target_items(endpoint: str, items: list[dict] | None) -> list[Browse
for item in items or []: for item in items or []:
profile = str(item.get("profile") or "default") profile = str(item.get("profile") or "default")
display = str(item.get("displayName") or profile) display = str(item.get("displayName") or profile)
display_name = _remote_display_name(endpoint, profile, display)
browser_name = item.get("browserName") or item.get("name")
targets.append( targets.append(
BrowserTarget( BrowserTarget(
profile=profile, profile=profile,
display_name=_remote_display_name(endpoint, profile, display), display_name=display_name,
socket_path="", socket_path="",
remote=endpoint, remote=endpoint,
browser_name=str(browser_name) if browser_name else None,
display_group=display_name.rsplit(":", 1)[0],
) )
) )
return targets return targets
@@ -52,15 +56,21 @@ def _remote_browser_targets(key=None, *, suppress_pq_warning: bool = False) -> l
continue continue
return targets return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: def remote_targets_for_alias(alias: str | None, key=None) -> list[BrowserTarget]:
"""Resolve a user-facing remote alias such as 'host:profile' to a target.""" """Return remote targets matching a user-facing alias.
Exact browser aliases such as ``host:profile`` return one target. Endpoint
aliases such as ``host`` or ``host:8765`` may return multiple targets, which
lets read/list SDK commands fan out while command dispatch can still reject
the ambiguous target.
"""
if not alias: if not alias:
return None return []
targets = _remote_browser_targets() targets = _remote_browser_targets(key=key) if key is not None else _remote_browser_targets()
for target in targets: for target in targets:
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
if alias in {target.display_name, endpoint_profile}: if alias in {target.display_name, endpoint_profile}:
return target return [target]
endpoint_matches = [] endpoint_matches = []
for target in targets: for target in targets:
@@ -69,16 +79,21 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
remote_host, sep, _remote_port = target.remote.rpartition(":") remote_host, sep, _remote_port = target.remote.rpartition(":")
if alias == target.remote or (sep and alias == remote_host): if alias == target.remote or (sep and alias == remote_host):
endpoint_matches.append(target) endpoint_matches.append(target)
if len(endpoint_matches) == 1: return endpoint_matches
return endpoint_matches[0]
if len(endpoint_matches) > 1: def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
aliases = [target.profile for target in endpoint_matches] """Resolve a user-facing remote alias such as 'host:profile' to a target."""
endpoint = endpoint_matches[0].remote or alias matches = remote_targets_for_alias(alias)
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
aliases = [target.profile for target in matches]
endpoint = matches[0].remote or alias or "remote"
examples = "\n".join( examples = "\n".join(
f" browser-cli --remote {endpoint} --browser {a} ..." f" browser-cli --remote {endpoint} --browser {a} ..."
for a in aliases for a in aliases
) )
display_aliases = [target.display_name for target in endpoint_matches] display_aliases = [target.display_name for target in matches]
shorthand_examples = "\n".join( shorthand_examples = "\n".join(
f" browser-cli --browser {a} ..." f" browser-cli --browser {a} ..."
for a in display_aliases for a in display_aliases
+2
View File
@@ -17,6 +17,8 @@ class BrowserTarget:
display_name: str display_name: str
socket_path: str socket_path: str
remote: str | None = None remote: str | None = None
browser_name: str | None = None
display_group: str | None = None
def is_reachable_unix_endpoint(endpoint: str) -> bool: def is_reachable_unix_endpoint(endpoint: str) -> bool:
"""Return True when a Unix socket path exists and accepts connections.""" """Return True when a Unix socket path exists and accepts connections."""
+61 -47
View File
@@ -22,60 +22,74 @@ tab_option = click.option("--tab", "tab_id", type=int, default=None, help="Tab I
def gentle_mode_option(help_text: str): def gentle_mode_option(help_text: str):
"""Reusable ``--gentle-mode`` Click option (throttle mode for large operations).""" """Reusable ``--gentle-mode`` Click option (throttle mode for large operations)."""
return click.option( return click.option(
"--gentle-mode", "--gentle-mode",
type=click.Choice(GENTLE_MODES), type=click.Choice(GENTLE_MODES),
default="auto", default="auto",
show_default=True, show_default=True,
help=help_text, help=help_text,
) )
def print_counts(result, noun: str, *, single_suffix: str = "") -> None: def print_counts(result, noun: str, *, single_suffix: str = "") -> None:
"""Render a count result. """Render a count result.
In multi-browser mode (*result* is a :class:`~browser_cli.BrowserCounts`) print a 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. per-browser table with a Total row; otherwise print a single ``N noun(s)`` line.
""" """
if isinstance(result, BrowserCounts): if isinstance(result, BrowserCounts):
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Browser") table.add_column("Browser", no_wrap=True)
table.add_column(f"{noun.capitalize()}s", justify="right") table.add_column(f"{noun.capitalize()}s", justify="right")
for name, count in result.by_browser.items(): rendered_groups: set[str] = set()
table.add_row(name, str(count)) for name, count in result.by_browser.items():
table.add_row("Total", str(result.total)) group = result.browser_groups.get(name)
_console.print(table) if group:
else: if group not in rendered_groups:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}") group_total = sum(
browser_count
for browser_name, browser_count in result.by_browser.items()
if result.browser_groups.get(browser_name) == group
)
table.add_row(f"[bold]{group}[/bold]", str(group_total))
rendered_groups.add(group)
display_name = name.removeprefix(f"{group}:")
table.add_row(f" {display_name}", str(count))
else:
table.add_row(name, str(count))
table.add_row("Total", str(result.total))
_console.print(table)
else:
_console.print(f"[bold]{result}[/bold] {noun}(s){single_suffix}")
def client_from_ctx() -> BrowserCLI: def client_from_ctx() -> BrowserCLI:
"""Build a BrowserCLI from the root context's global options. """Build a BrowserCLI from the root context's global options.
Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group. Reads ``browser``/``remote``/``key`` set by the top-level ``main`` group.
Falls back to an unconfigured client when a command group is invoked Falls back to an unconfigured client when a command group is invoked
standalone (e.g. in unit tests). standalone (e.g. in unit tests).
""" """
obj = click.get_current_context().find_root().obj or {} obj = click.get_current_context().find_root().obj or {}
return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key")) return BrowserCLI(browser=obj.get("browser"), remote=obj.get("remote"), key=obj.get("key"))
def handle_errors(fn): def handle_errors(fn):
"""Decorate a CLI command so SDK exceptions become clean errors + exit(1). """Decorate a CLI command so SDK exceptions become clean errors + exit(1).
Apply as the innermost decorator (directly above ``def``) so Click's option Apply as the innermost decorator (directly above ``def``) so Click's option
decorators attach their params to the wrapper. decorators attach their params to the wrapper.
""" """
@functools.wraps(fn) @functools.wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
return fn(*args, **kwargs) return fn(*args, **kwargs)
except BrowserNotConnected as e: except BrowserNotConnected as e:
_console.print(f"[red]Error:[/red] {e}") _console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
except PermissionError as e: except PermissionError as e:
_console.print(f"[red]Blocked:[/red] {e}") _console.print(f"[red]Blocked:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
except RuntimeError as e: except RuntimeError as e:
_console.print(f"[red]Browser error:[/red] {e}") _console.print(f"[red]Browser error:[/red] {e}")
raise SystemExit(1) raise SystemExit(1)
return wrapper return wrapper
+28 -5
View File
@@ -36,7 +36,7 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
if alias in profiles and alias != target_profile: if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists") raise click.ClickException(f"Browser alias '{alias}' already exists")
def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False): def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_remote_warning=False, profile_group=None):
"""Query clients.list for one target and append each, tagged with *label*.""" """Query clients.list for one target and append each, tagged with *label*."""
if quiet_remote_warning: if quiet_remote_warning:
result = send_command( result = send_command(
@@ -50,6 +50,8 @@ def _append_clients(into, label, *, profile=None, remote=None, key=None, quiet_r
result = send_command("clients.list", profile=profile, remote=remote, key=key) result = send_command("clients.list", profile=profile, remote=remote, key=key)
for c in (result or []): for c in (result or []):
c["profile"] = label c["profile"] = label
if profile_group:
c["profileGroup"] = profile_group
into.append(c) into.append(c)
@click.group("clients", invoke_without_command=True) @click.group("clients", invoke_without_command=True)
@@ -89,7 +91,14 @@ def _collect_remote_alias_clients(all_clients: list, browser_alias: str, key) ->
sys.exit(1) sys.exit(1)
for target in targets: for target in targets:
try: try:
_append_clients(all_clients, target.display_name, profile=target.profile, remote=resolved.remote, key=key) _append_clients(
all_clients,
target.display_name,
profile=target.profile,
remote=resolved.remote,
key=key,
profile_group=target.display_group,
)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
continue continue
@@ -109,10 +118,11 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
for profile_name, sock_path in profiles.items(): for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path) display_profile = display_browser_name(profile_name, sock_path)
try: try:
_append_clients(all_clients, display_profile, profile=profile_name) _append_clients(all_clients, display_profile, profile=profile_name, profile_group="local")
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
all_clients.append({ all_clients.append({
"profile": display_profile, "profile": display_profile,
"profileGroup": "local",
"name": "", "name": "",
"version": "", "version": "",
"extensionVersion": "disconnected", "extensionVersion": "disconnected",
@@ -130,6 +140,7 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
profile=target.profile, profile=target.profile,
remote=target.remote, remote=target.remote,
quiet_remote_warning=True, quiet_remote_warning=True,
profile_group=target.display_group,
) )
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
continue continue
@@ -137,13 +148,25 @@ def _collect_local_and_saved_remote_clients(all_clients: list) -> None:
def _print_clients(all_clients: list) -> None: def _print_clients(all_clients: list) -> None:
from rich.table import Table from rich.table import Table
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile") table.add_column("Profile", no_wrap=True)
table.add_column("Browser") table.add_column("Browser")
table.add_column("Version") table.add_column("Version")
table.add_column("Extension Version") table.add_column("Extension Version")
rendered_groups: set[str] = set()
groups = {c.get("profileGroup") for c in all_clients if c.get("profileGroup")}
grouped = bool(groups and groups != {"local"})
for c in all_clients: for c in all_clients:
group = c.get("profileGroup") if grouped else None
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", "", "", "")
rendered_groups.add(group)
profile = str(c.get("profile", "")).removeprefix(f"{group}:")
profile = f" {profile}"
else:
profile = c.get("profile", "")
table.add_row( table.add_row(
c.get("profile", ""), profile,
c.get("name", ""), c.get("name", ""),
c.get("version", ""), c.get("version", ""),
c.get("extensionVersion", ""), c.get("extensionVersion", ""),
+9 -23
View File
@@ -1,33 +1,19 @@
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from rich.console import Console from rich.console import Console
from rich.table import Table
console = Console() console = Console()
def _print_groups(groups, *, show_browser: bool = False) -> None: def _print_groups(groups, *, show_browser: bool = False) -> None:
if not groups: columns = [
console.print("[yellow]No groups found[/yellow]") ("ID", lambda g: g.id),
return ("Name", lambda g: g.title or ""),
table = Table(show_header=True, header_style="bold cyan") ("Color", lambda g: g.color or ""),
if show_browser: ("Collapsed", lambda g: "yes" if g.collapsed else "no"),
table.add_column("Browser") ("Tabs", lambda g: g.tab_count),
table.add_column("ID", style="dim", no_wrap=True) ]
table.add_column("Name") print_browser_grouped_table_rows(groups, columns, console=console, empty_message="[yellow]No groups found[/yellow]")
table.add_column("Color", width=10)
table.add_column("Collapsed", width=10)
table.add_column("Tabs", width=6)
for g in groups:
row = [
(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") @click.group("groups")
def group_group(): def group_group():
+18 -8
View File
@@ -8,6 +8,7 @@ from rich.table import Table
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.commands import handle_errors from browser_cli.commands import handle_errors
from browser_cli.commands.rendering import print_browser_grouped_table_rows
from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
console = Console() console = Console()
@@ -23,14 +24,23 @@ def remote_group():
def remote_status(endpoint, key): def remote_status(endpoint, key):
"""Probe a remote endpoint and show server/client status.""" """Probe a remote endpoint and show server/client status."""
client = BrowserCLI(remote=endpoint, key=key) client = BrowserCLI(remote=endpoint, key=key)
clients = client.clients() clients = [
table = Table(show_header=True, header_style="bold cyan") {**item, "profileLabel": item.get("profile", ""), "profileGroup": endpoint}
table.add_column("Profile") for item in client.clients()
table.add_column("Browser") ]
table.add_column("Extension") columns = [
for item in clients: ("Browser", lambda item: item.get("name", "")),
table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", ""))) ("Extension", lambda item: item.get("extensionVersion", "")),
console.print(table) ]
print_browser_grouped_table_rows(
clients,
columns,
console=console,
empty_message="[yellow]No browser clients found[/yellow]",
browser_getter=lambda item: item.get("profileLabel", ""),
group_getter=lambda item: item.get("profileGroup", ""),
browser_header="Profile",
)
@remote_group.command("trust") @remote_group.command("trust")
@click.argument("endpoint") @click.argument("endpoint")
@@ -0,0 +1,60 @@
"""Reusable rendering helpers for CLI command modules."""
from browser_cli.commands.rendering.common import (
Column,
item_value,
print_browser_grouped_table_rows,
print_table_rows,
print_tree,
shorten,
terminal_width,
tree_title_limit,
tree_url_limit,
)
from browser_cli.commands.rendering.labels import (
BROWSER_FAMILY_STYLES,
DEFAULT_BROWSER_STYLE,
DEFAULT_SCOPE,
browser_label_style,
group_tree_label,
no_wrap_text,
scoped_browser_label,
tab_tree_label,
)
from browser_cli.commands.rendering.tabs_tree import (
TabsTreeBuilder,
browser_label_key,
browser_scope,
build_tabs_tree,
tab_group_id,
tab_sort_key,
tab_window_id,
)
from browser_cli.commands.rendering.windows_tree import build_windows_tree
__all__ = [
"BROWSER_FAMILY_STYLES",
"Column",
"DEFAULT_BROWSER_STYLE",
"DEFAULT_SCOPE",
"TabsTreeBuilder",
"browser_label_key",
"browser_label_style",
"browser_scope",
"build_tabs_tree",
"build_windows_tree",
"group_tree_label",
"item_value",
"no_wrap_text",
"print_browser_grouped_table_rows",
"print_table_rows",
"print_tree",
"scoped_browser_label",
"shorten",
"tab_group_id",
"tab_sort_key",
"tab_tree_label",
"tab_window_id",
"terminal_width",
"tree_title_limit",
"tree_url_limit",
]
+137
View File
@@ -0,0 +1,137 @@
"""Common Rich rendering helpers for CLI command modules."""
from __future__ import annotations
import shutil
from collections.abc import Callable, Mapping, Sequence
from typing import TypeVar, cast
from rich.console import Console
from rich.table import Table
from rich.tree import Tree
Row = object
CellValue = object
Column = tuple[str, Callable[[Row], CellValue]]
T = TypeVar("T")
def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None:
"""Read *name* from a dict-like or attribute object."""
if isinstance(item, Mapping):
return cast(Mapping[str, CellValue], item).get(name, default)
return getattr(item, name, default)
def text_value(value: CellValue | None, default: str = "") -> str:
"""Coerce a nullable cell value to display text."""
return default if value is None else str(value)
def int_value(value: CellValue | None, default: int = 0) -> int:
"""Coerce a cell value to int, falling back when conversion is not possible."""
try:
return int(cast(int | str | float | bool, value))
except (TypeError, ValueError):
return default
def shorten(value: str | None, limit: int) -> str:
"""Return *value* shortened to *limit* cells-ish, using an ellipsis."""
value = value or ""
return value if len(value) <= limit else value[:max(0, limit - 1)] + ""
def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int:
"""Best-effort terminal width for interactive and redirected output.
Rich falls back to 80 columns when stdout is redirected. browser-cli output is
often piped into files for inspection, so also consult ``shutil``/``COLUMNS``
and prefer the wider value.
"""
rich_width = (console.width if console is not None else 0) or 0
shell_width = shutil.get_terminal_size((fallback, 20)).columns
return max(rich_width, shell_width)
def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int:
"""Title width for tree labels, reserving space for branches/IDs/metadata."""
reserve = 48 if show_urls else 32
if show_browser:
reserve += 4
return max(50, terminal_width(console) - reserve)
def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int:
"""URL width for tree labels when URLs are displayed."""
return max(35, terminal_width(console) - title_limit - 40)
def print_tree(tree: Tree, *, console: Console | None = None) -> None:
"""Render a Rich tree using the detected full terminal width."""
Console(width=terminal_width(console)).print(tree)
def print_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render a small Rich table from arbitrary row objects."""
if not rows:
console.print(empty_message)
return
table = Table(show_header=show_header, header_style=header_style)
for header, _getter in columns:
table.add_column(header)
for row in rows:
table.add_row(*[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
def print_browser_grouped_table_rows(
rows: Sequence[Row],
columns: Sequence[Column],
*,
console: Console,
empty_message: str,
browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"),
group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")),
browser_header: str = "Browser",
show_header: bool = True,
header_style: str = "bold cyan",
) -> None:
"""Render rows with optional local/remote browser grouping.
Rows without a browser label are rendered as a normal table. Rows with
``browser_group``/``browserGroup`` get a group header (for example ``local``
or a remote host) and a short indented profile label below it.
"""
if not rows:
console.print(empty_message)
return
show_browser = any(bool(browser_getter(row)) for row in rows)
if not show_browser:
print_table_rows(
rows,
columns,
console=console,
empty_message=empty_message,
show_header=show_header,
header_style=header_style,
)
return
table = Table(show_header=show_header, header_style=header_style)
table.add_column(browser_header, no_wrap=True)
for header, _getter in columns:
table.add_column(header)
rendered_groups: set[str] = set()
for row in rows:
browser = text_value(browser_getter(row))
group = text_value(group_getter(row))
if group:
if group not in rendered_groups:
table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns])
rendered_groups.add(group)
browser = browser.removeprefix(f"{group}:")
browser = f" {browser}"
table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns])
Console(width=terminal_width(console)).print(table)
+63
View File
@@ -0,0 +1,63 @@
"""Rich label helpers for tab/window tree renderers."""
from __future__ import annotations
from rich.text import Text
from browser_cli.commands.rendering.common import Row, int_value, item_value, shorten, text_value
BROWSER_FAMILY_STYLES = {
"firefox": "orange1",
"chrome": "cyan",
"chromium": "cyan",
"brave": "cyan",
"edge": "cyan",
"vivaldi": "cyan",
}
DEFAULT_SCOPE = "local"
DEFAULT_BROWSER_STYLE = "bold cyan"
def no_wrap_text() -> Text:
"""Text configured for one-line tree labels with edge ellipsis."""
return Text(no_wrap=True, overflow="ellipsis")
def tab_tree_label(tab: Row, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text:
"""Reusable one-line label for a browser tab in tree views."""
label = no_wrap_text()
label.append(f"[{text_value(item_value(tab, 'id'))}] ", style="dim")
label.append(shorten(text_value(item_value(tab, 'title'), "(untitled)") or "(untitled)", title_limit))
if bool(item_value(tab, "active", False)):
label.append(" *", style="green")
url = text_value(item_value(tab, "url"))
if show_urls and url:
label.append("", style="dim")
label.append(shorten(url, url_limit), style="dim")
return label
def group_tree_label(group_id: object, group: Row | None, *, title_limit: int) -> Text:
"""Reusable one-line label for a browser tab group in tree views."""
title = text_value(item_value(group, "title", "") if group is not None else "") or f"Group {group_id}"
color = text_value(item_value(group, "color", "") if group is not None else "") or "group"
count = int_value(item_value(group, "tab_count", item_value(group, "tabCount", 0)) if group is not None else 0)
collapsed = bool(item_value(group, "collapsed", False)) if group is not None else False
label = no_wrap_text()
label.append(shorten(title, title_limit), style="bold")
meta = [color]
if count:
meta.append(f"{count} tab" + ("" if count == 1 else "s"))
if collapsed:
meta.append("collapsed")
label.append(" (" + ", ".join(meta) + ")", style="dim")
return label
def browser_label_style(browser_name: str | None) -> str:
"""Return a Rich style for a browser family label."""
name = (browser_name or "").lower()
for family, style in BROWSER_FAMILY_STYLES.items():
if family in name:
return style
return DEFAULT_BROWSER_STYLE
def scoped_browser_label(browser: str, scope: str, *, grouped: bool) -> str:
"""Shorten browser labels under a remote/local group node."""
prefix = f"{scope}:"
return browser[len(prefix):] if grouped and browser.startswith(prefix) else browser
+185
View File
@@ -0,0 +1,185 @@
"""Tabs tree renderer."""
from __future__ import annotations
from collections.abc import Iterable
from rich.console import Console
from rich.text import Text
from rich.tree import Tree
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering.labels import (
DEFAULT_BROWSER_STYLE,
DEFAULT_SCOPE,
browser_label_style,
group_tree_label,
scoped_browser_label,
tab_tree_label,
)
GroupId = object
GroupKey = tuple[str, str, int | None, GroupId]
TreeNodeKey = tuple[str, str]
WindowNodeKey = tuple[str, str, int]
BrowserGroupNodeKey = tuple[str, str, int, GroupId]
def browser_scope(item: Row) -> str:
"""Return the remote/local scope key used by tree renderers."""
return text_value(item_value(item, "browser_group")) or DEFAULT_SCOPE
def browser_label_key(item: Row) -> str:
"""Return the browser/profile key used by tree renderers."""
return text_value(item_value(item, "browser")) or DEFAULT_SCOPE
def tab_window_id(tab: Row) -> int:
"""Return a stable window id from object or dict-shaped tab responses."""
return int_value(item_value(tab, "window_id", item_value(tab, "windowId", 0)))
def tab_group_id(tab: Row) -> GroupId | None:
"""Return a tab group id from object or dict-shaped tab responses."""
group_id = item_value(tab, "group_id", item_value(tab, "groupId"))
return None if group_id is None else group_id
def tab_sort_key(tab: Row) -> tuple[str, str, int, int, int, int]:
"""Stable tab ordering across multi-browser responses."""
group_id = tab_group_id(tab)
return (
browser_scope(tab),
browser_label_key(tab),
tab_window_id(tab),
int_value(item_value(tab, "index", 0)),
int_value(group_id, -1) if group_id is not None else -1,
int_value(item_value(tab, "id", 0)),
)
class TabsTreeBuilder:
"""Stateful builder for the browser tabs tree.
The tree has optional scope nodes (remote host/local), then browser/profile,
then window, then browser tab-groups/tabs. Keeping this state in a helper
keeps ``build_tabs_tree`` small while preserving stable node reuse.
"""
tabs: list[Row]
groups: list[Row]
show_urls: bool
show_browser: bool
group_by_scope: bool
title_limit: int
url_limit: int
root: Tree
group_info: dict[GroupKey, Row]
browser_styles: dict[str, str]
scope_nodes: dict[str, Tree]
browser_nodes: dict[TreeNodeKey, Tree]
window_nodes: dict[WindowNodeKey, Tree]
group_nodes: dict[BrowserGroupNodeKey, Tree]
def __init__(
self,
tabs: Iterable[Row],
groups: Iterable[Row],
*,
console: Console,
show_urls: bool = False,
):
self.tabs = sorted(tabs, key=tab_sort_key)
self.groups = list(groups)
self.show_urls = show_urls
self.show_browser = any(bool(item_value(tab, "browser")) for tab in self.tabs)
self.group_by_scope = any(bool(item_value(item, "browser_group")) for item in self.tabs + self.groups)
self.title_limit = tree_title_limit(console=console, show_browser=self.show_browser, show_urls=show_urls)
self.url_limit = tree_url_limit(self.title_limit, console=console)
self.root = Tree("[bold]Tabs[/bold]")
self.group_info = self._group_info()
self.browser_styles = self._browser_styles()
self.scope_nodes = {}
self.browser_nodes = {}
self.window_nodes = {}
self.group_nodes = {}
def build(self) -> Tree:
for tab in self.tabs:
self._add_tab(tab)
return self.root
def _group_info(self) -> dict[GroupKey, Row]:
return {
(
browser_scope(group),
browser_label_key(group),
int_value(item_value(group, "window_id", item_value(group, "windowId")), 0),
item_value(group, "id"),
): group
for group in self.groups
}
def _browser_styles(self) -> dict[str, str]:
styles: dict[str, str] = {}
for item in self.tabs + self.groups:
key = browser_label_key(item)
styles.setdefault(key, browser_label_style(text_value(item_value(item, "browser_name")) or None))
return styles
def _scope_node(self, scope: str) -> Tree:
if not self.group_by_scope:
return self.root
node = self.scope_nodes.get(scope)
if node is None:
node = self.root.add(Text(scope, style="bold"))
self.scope_nodes[scope] = node
return node
def _browser_node(self, scope: str, browser: str) -> Tree:
key = (scope, browser)
node = self.browser_nodes.get(key)
if node is None:
parent = self._scope_node(scope)
if self.show_browser:
label = scoped_browser_label(browser, scope, grouped=self.group_by_scope)
node = parent.add(Text(label, style=self.browser_styles.get(browser, DEFAULT_BROWSER_STYLE)))
else:
node = parent
self.browser_nodes[key] = node
return node
def _window_node(self, scope: str, browser: str, window_id: int) -> Tree:
key = (scope, browser, window_id)
node = self.window_nodes.get(key)
if node is None:
node = self._browser_node(scope, browser).add(f"Window {window_id}")
self.window_nodes[key] = node
return node
def _group_node(self, scope: str, browser: str, window_id: int, group_id: GroupId, parent: Tree) -> Tree:
key = (scope, browser, window_id, group_id)
node = self.group_nodes.get(key)
if node is None:
group = self.group_info.get(key) or self.group_info.get((scope, browser, None, group_id))
node = parent.add(group_tree_label(group_id, group, title_limit=self.title_limit))
self.group_nodes[key] = node
return node
def _add_tab(self, tab: Row) -> None:
scope = browser_scope(tab)
browser = browser_label_key(tab)
window_id = tab_window_id(tab)
window_node = self._window_node(scope, browser, window_id)
group_id = tab_group_id(tab)
parent = window_node if group_id is None else self._group_node(scope, browser, window_id, group_id, window_node)
parent.add(tab_tree_label(
tab,
title_limit=self.title_limit,
show_urls=self.show_urls,
url_limit=self.url_limit,
))
def build_tabs_tree(
tabs: Iterable[Row],
groups: Iterable[Row],
*,
console: Console,
show_urls: bool = False,
) -> Tree:
"""Build a remote/local → browser → window → group/tab tree from tab responses."""
return TabsTreeBuilder(tabs, groups, console=console, show_urls=show_urls).build()
@@ -0,0 +1,38 @@
"""Windows tree renderer."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from rich.console import Console
from rich.tree import Tree
from browser_cli.commands.rendering.common import Row, int_value, item_value, text_value, tree_title_limit, tree_url_limit
from browser_cli.commands.rendering.labels import tab_tree_label
WindowRow = Mapping[str, object]
def build_windows_tree(windows: Iterable[WindowRow], tabs: Iterable[Row], *, console: Console) -> Tree:
"""Build a window → tab tree from window and tab responses."""
windows = list(windows)
tabs = list(tabs)
title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True)
url_limit = tree_url_limit(title_limit, console=console)
root = Tree("[bold]Windows[/bold]")
for window in sorted(windows, key=lambda item: (text_value(item.get("browser")), int_value(item.get("id")))):
window_id = int_value(window.get("id"))
label = f"Window {window_id}"
alias = text_value(window.get("alias"))
browser = text_value(window.get("browser"))
if alias:
label += f" ({alias})"
if browser:
label = f"{browser}: " + label
node = root.add(label)
window_tabs = [
tab for tab in tabs
if int_value(item_value(tab, "window_id", item_value(tab, "windowId"))) == window_id
and (not browser or text_value(item_value(tab, "browser")) == browser)
]
for tab in sorted(window_tabs, key=lambda item: int_value(item_value(item, "index", 0))):
node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit))
return root
+44 -42
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, cast
import click import click
from rich.console import Console from rich.console import Console
@@ -12,25 +14,25 @@ from browser_cli.commands import client_from_ctx, handle_errors
console = Console() console = Console()
def _load_steps(path: Path): def _load_steps(path: Path):
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
if path.suffix.lower() in {".yaml", ".yml"}: if path.suffix.lower() in {".yaml", ".yml"}:
try: try:
import yaml # type: ignore yaml = cast(Any, importlib.import_module("yaml"))
except Exception as exc: except Exception as exc:
raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
return yaml.safe_load(text) return yaml.safe_load(text)
return json.loads(text) return json.loads(text)
def _parse_step(step): def _parse_step(step):
if isinstance(step, str): if isinstance(step, str):
return step, {} return step, {}
if isinstance(step, dict): if isinstance(step, dict):
if "command" in step: if "command" in step:
return step["command"], step.get("args") or {} return step["command"], step.get("args") or {}
if len(step) == 1: if len(step) == 1:
command, args = next(iter(step.items())) command, args = next(iter(step.items()))
return command, args or {} return command, args or {}
raise click.ClickException(f"Invalid script step: {step!r}") raise click.ClickException(f"Invalid script step: {step!r}")
@click.command("script") @click.command("script")
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@@ -41,28 +43,28 @@ def _parse_step(step):
@click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots") @click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
@handle_errors @handle_errors
def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool): 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.""" """Run a JSON/YAML batch script of browser-cli wire commands."""
steps = _load_steps(file) steps = _load_steps(file)
if not isinstance(steps, list): if not isinstance(steps, list):
raise click.ClickException("Script root must be a list") raise click.ClickException("Script root must be a list")
client = client_from_ctx() client = client_from_ctx()
policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous) policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
results = [] results = []
for index, step in enumerate(steps, start=1): for index, step in enumerate(steps, start=1):
command, args = _parse_step(step) command, args = _parse_step(step)
try: try:
assert_command_allowed(command, policy) assert_command_allowed(command, policy)
result = client.command(command, args) result = client.command(command, args)
results.append({"index": index, "command": command, "ok": True, "result": result}) results.append({"index": index, "command": command, "ok": True, "result": result})
if not json_output: if not json_output:
console.print(f"[green]✓[/green] {index}: {command}") console.print(f"[green]✓[/green] {index}: {command}")
except Exception as exc: except Exception as exc:
results.append({"index": index, "command": command, "ok": False, "error": str(exc)}) results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
if not continue_on_error: if not continue_on_error:
if json_output: if json_output:
click.echo(json.dumps(results, indent=2, default=str)) click.echo(json.dumps(results, indent=2, default=str))
raise raise
if not json_output: if not json_output:
console.print(f"[red]✗[/red] {index}: {command}: {exc}") console.print(f"[red]✗[/red] {index}: {command}: {exc}")
if json_output: if json_output:
click.echo(json.dumps(results, indent=2, default=str)) click.echo(json.dumps(results, indent=2, default=str))
+10 -14
View File
@@ -105,24 +105,20 @@ def session_diff(name_a, name_b):
def session_list(): def session_list():
"""List all saved sessions.""" """List all saved sessions."""
from datetime import datetime from datetime import datetime
from rich.table import Table from browser_cli.commands.rendering import print_browser_grouped_table_rows
sessions = client_from_ctx().session.list() sessions = client_from_ctx().session.list()
if not sessions: if not sessions:
console.print("[yellow]No saved sessions[/yellow]") console.print("[yellow]No saved sessions[/yellow]")
return return
show_browser = any("browser" in s for s in sessions) def saved_at(session):
table = Table(show_header=True, header_style="bold cyan") return datetime.fromtimestamp(session["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if session.get("savedAt") else ""
if show_browser:
table.add_column("Browser") columns = [
table.add_column("Name") ("Name", lambda session: session["name"]),
table.add_column("Tabs", width=6) ("Tabs", lambda session: session["tabs"]),
table.add_column("Saved at") ("Saved at", saved_at),
for s in sessions: ]
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else "" print_browser_grouped_table_rows(sessions, columns, console=console, empty_message="[yellow]No saved sessions[/yellow]")
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
@session_group.command("remove") @session_group.command("remove")
@click.argument("name") @click.argument("name")
+94 -130
View File
@@ -2,78 +2,42 @@ import base64
import binascii import binascii
import click import click
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
from browser_cli.commands.rendering import build_tabs_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.tree import Tree
console = Console() console = Console()
def _print_tabs(tabs, *, show_browser: bool = False) -> None: def _print_tabs(tabs, *, show_browser: bool = False) -> None:
if not tabs: columns = [
console.print("[yellow]No tabs found[/yellow]") ("ID", lambda tab: tab.id),
return ("Window", lambda tab: tab.window_id),
table = Table(show_header=True, header_style="bold cyan") ("Active", lambda tab: "[green]✓[/green]" if tab.active else ""),
if show_browser: ("Muted", lambda tab: "[yellow]✓[/yellow]" if tab.muted else ""),
table.add_column("Browser", no_wrap=True) ("Title", lambda tab: (tab.title or "")[:60]),
table.add_column("ID", style="dim", no_wrap=True) ("URL", lambda tab: (tab.url or "")[:80]),
table.add_column("Window", no_wrap=True) ]
table.add_column("Active", width=7) print_browser_grouped_table_rows(tabs, columns, console=console, empty_message="[yellow]No tabs found[/yellow]")
table.add_column("Muted", width=7)
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.active else ""
muted = "[yellow]✓[/yellow]" if t.muted else ""
row = [
(t.browser or "") if show_browser else None,
str(t.id),
str(t.window_id),
active,
muted,
(t.title or "")[:60],
(t.url or "")[:80],
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("tabs") @click.group("tabs")
def tabs_group(): def tabs_group():
"""Manage browser tabs.""" """Manage browser tabs."""
@tabs_group.command("list") @tabs_group.command("list")
@handle_errors @handle_errors
def tabs_list(): def tabs_list():
"""List all open tabs across all windows.""" """List all open tabs across all windows."""
tabs = client_from_ctx().tabs.list() tabs = client_from_ctx().tabs.list()
_print_tabs(tabs, show_browser=any(t.browser for t in tabs)) _print_tabs(tabs, show_browser=any(t.browser for t in tabs))
@tabs_group.command("tree") @tabs_group.command("tree")
@click.option("--urls", "show_urls", is_flag=True, help="Show shortened URLs next to tab titles")
@handle_errors @handle_errors
def tabs_tree(): def tabs_tree(show_urls):
"""Show tabs grouped as a window/group 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)) client = client_from_ctx()
root = Tree("[bold]Tabs[/bold]") root = build_tabs_tree(client.tabs.list(), client.groups.list(), console=console, show_urls=show_urls)
browsers = {} print_tree(root, console=console)
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") @tabs_group.command("close")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@@ -82,9 +46,9 @@ def tabs_tree():
@gentle_mode_option("Throttle mode for large close operations.") @gentle_mode_option("Throttle mode for large close operations.")
@handle_errors @handle_errors
def tabs_close(tab_id, inactive, duplicates, gentle_mode): def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs.""" """Close a tab, all inactive tabs, or all duplicate tabs."""
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode) 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]") console.print(f"[green]Closed {count} tab(s)[/green]")
@tabs_group.command("move") @tabs_group.command("move")
@click.argument("tab_id", type=int) @click.argument("tab_id", type=int)
@@ -97,123 +61,123 @@ def tabs_close(tab_id, inactive, duplicates, gentle_mode):
@click.option("--index", type=int, default=None, help="Absolute position index in target") @click.option("--index", type=int, default=None, help="Absolute position index in target")
@handle_errors @handle_errors
def tabs_move(tab_id, forward, backward, group_id, window_id, index): def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward or --right/--left for relative movement.""" """Move a tab. Use --forward/--backward or --right/--left for relative movement."""
client_from_ctx().tabs.move( client_from_ctx().tabs.move(
tab_id, forward=forward, backward=backward, tab_id, forward=forward, backward=backward,
group_id=group_id, window_id=window_id, index=index, group_id=group_id, window_id=window_id, index=index,
) )
console.print("[green]Tab moved[/green]") console.print("[green]Tab moved[/green]")
@tabs_group.command("active") @tabs_group.command("active")
@click.argument("tab_id", type=int) @click.argument("tab_id", type=int)
@handle_errors @handle_errors
def tabs_active(tab_id): def tabs_active(tab_id):
"""Switch browser focus to a tab.""" """Switch browser focus to a tab."""
client_from_ctx().tabs.activate(tab_id) client_from_ctx().tabs.activate(tab_id)
console.print(f"[green]Switched to tab {tab_id}[/green]") console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status") @tabs_group.command("status")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_status(tab_id): def tabs_status(tab_id):
"""Show status for the active tab or a specific tab.""" """Show status for the active tab or a specific tab."""
tab = client_from_ctx().tabs.status(tab_id) tab = client_from_ctx().tabs.status(tab_id)
table = Table(show_header=False) table = Table(show_header=False)
table.add_column("Field", style="bold cyan") table.add_column("Field", style="bold cyan")
table.add_column("Value") table.add_column("Value")
table.add_row("ID", str(tab.id)) table.add_row("ID", str(tab.id))
table.add_row("Window", str(tab.window_id)) table.add_row("Window", str(tab.window_id))
table.add_row("Active", "yes" if tab.active else "no") table.add_row("Active", "yes" if tab.active else "no")
table.add_row("Muted", "yes" if tab.muted else "no") table.add_row("Muted", "yes" if tab.muted else "no")
table.add_row("Title", tab.title or "") table.add_row("Title", tab.title or "")
table.add_row("URL", tab.url or "") table.add_row("URL", tab.url or "")
console.print(table) console.print(table)
@tabs_group.command("filter") @tabs_group.command("filter")
@click.argument("pattern") @click.argument("pattern")
@handle_errors @handle_errors
def tabs_filter(pattern): def tabs_filter(pattern):
"""List tabs whose URL contains PATTERN.""" """List tabs whose URL contains PATTERN."""
_print_tabs(client_from_ctx().tabs.filter(pattern)) _print_tabs(client_from_ctx().tabs.filter(pattern))
@tabs_group.command("count") @tabs_group.command("count")
@click.argument("pattern", required=False) @click.argument("pattern", required=False)
@handle_errors @handle_errors
def tabs_count(pattern): def tabs_count(pattern):
"""Count open tabs, optionally filtered by URL PATTERN.""" """Count open tabs, optionally filtered by URL PATTERN."""
label = f" matching '{pattern}'" if pattern else "" label = f" matching '{pattern}'" if pattern else ""
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label) print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
@tabs_group.command("query") @tabs_group.command("query")
@click.argument("search") @click.argument("search")
@handle_errors @handle_errors
def tabs_query(search): def tabs_query(search):
"""Search tabs by URL or title.""" """Search tabs by URL or title."""
_print_tabs(client_from_ctx().tabs.query(search)) _print_tabs(client_from_ctx().tabs.query(search))
@tabs_group.command("html") @tabs_group.command("html")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_html(tab_id): def tabs_html(tab_id):
"""Print the full HTML of a tab.""" """Print the full HTML of a tab."""
console.print(client_from_ctx().tabs.html(tab_id)) console.print(client_from_ctx().tabs.html(tab_id))
@tabs_group.command("dedupe") @tabs_group.command("dedupe")
@gentle_mode_option("Throttle mode for large dedupe operations.") @gentle_mode_option("Throttle mode for large dedupe operations.")
@handle_errors @handle_errors
def tabs_dedupe(gentle_mode): def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL).""" """Close duplicate tabs (keep the first occurrence of each URL)."""
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode) count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
console.print(f"[green]Closed {count} duplicate tab(s)[/green]") console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort") @tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
@gentle_mode_option("Throttle mode for large sort operations.") @gentle_mode_option("Throttle mode for large sort operations.")
@handle_errors @handle_errors
def tabs_sort(by, gentle_mode): def tabs_sort(by, gentle_mode):
"""Sort tabs within each window.""" """Sort tabs within each window."""
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode) client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
console.print(f"[green]Tabs sorted by {by}[/green]") console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows") @tabs_group.command("merge-windows")
@gentle_mode_option("Throttle mode for large merge operations.") @gentle_mode_option("Throttle mode for large merge operations.")
@handle_errors @handle_errors
def tabs_merge_windows(gentle_mode): def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window.""" """Move all tabs into the focused window."""
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode) count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute") @tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_mute(tab_id): def tabs_mute(tab_id):
"""Mute the active tab or a specific tab.""" """Mute the active tab or a specific tab."""
target = client_from_ctx().tabs.mute(tab_id) target = client_from_ctx().tabs.mute(tab_id)
console.print(f"[green]Muted tab {target}[/green]") console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute") @tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_unmute(tab_id): def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab.""" """Unmute the active tab or a specific tab."""
target = client_from_ctx().tabs.unmute(tab_id) target = client_from_ctx().tabs.unmute(tab_id)
console.print(f"[green]Unmuted tab {target}[/green]") console.print(f"[green]Unmuted tab {target}[/green]")
@tabs_group.command("pin") @tabs_group.command("pin")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_pin(tab_id): def tabs_pin(tab_id):
"""Pin the active tab or a specific tab.""" """Pin the active tab or a specific tab."""
target = client_from_ctx().tabs.pin(tab_id) target = client_from_ctx().tabs.pin(tab_id)
console.print(f"[green]Pinned tab {target}[/green]") console.print(f"[green]Pinned tab {target}[/green]")
@tabs_group.command("unpin") @tabs_group.command("unpin")
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@handle_errors @handle_errors
def tabs_unpin(tab_id): def tabs_unpin(tab_id):
"""Unpin the active tab or a specific tab.""" """Unpin the active tab or a specific tab."""
target = client_from_ctx().tabs.unpin(tab_id) target = client_from_ctx().tabs.unpin(tab_id)
console.print(f"[green]Unpinned tab {target}[/green]") console.print(f"[green]Unpinned tab {target}[/green]")
@tabs_group.command("watch-url") @tabs_group.command("watch-url")
@click.argument("pattern") @click.argument("pattern")
@@ -221,9 +185,9 @@ def tabs_unpin(tab_id):
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait") @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
@handle_errors @handle_errors
def tabs_watch_url(pattern, tab_id, timeout): def tabs_watch_url(pattern, tab_id, timeout):
"""Wait until the active (or specified) tab URL matches regex PATTERN.""" """Wait until the active (or specified) tab URL matches regex PATTERN."""
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
console.print(f"[green]URL matched:[/green] {tab.url}") console.print(f"[green]URL matched:[/green] {tab.url}")
@tabs_group.command("screenshot") @tabs_group.command("screenshot")
@click.argument("output", required=False, metavar="FILE") @click.argument("output", required=False, metavar="FILE")
@@ -232,21 +196,21 @@ def tabs_watch_url(pattern, tab_id, timeout):
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100") @click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
@handle_errors @handle_errors
def tabs_screenshot(output, tab_id, fmt, quality): def tabs_screenshot(output, tab_id, fmt, quality):
"""Capture a screenshot of the active (or specified) tab. """Capture a screenshot of the active (or specified) tab.
Saves to FILE if given, otherwise prints the base64 data URL. Saves to FILE if given, otherwise prints the base64 data URL.
""" """
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality) data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
if output: if output:
header = f"data:image/{fmt};base64," header = f"data:image/{fmt};base64,"
if not data_url.startswith(header): if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)") raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try: try:
raw = base64.b64decode(data_url[len(header):]) raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e: except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}") raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f: with open(output, "wb") as f:
f.write(raw) f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}") console.print(f"[green]Screenshot saved:[/green] {output}")
else: else:
console.print(data_url) console.print(data_url)
+26 -53
View File
@@ -1,87 +1,60 @@
import click import click
from browser_cli.commands import client_from_ctx, handle_errors from browser_cli.commands import client_from_ctx, handle_errors
from browser_cli.commands.rendering import build_windows_tree, print_browser_grouped_table_rows, print_tree
from rich.console import Console from rich.console import Console
from rich.table import Table
from rich.tree import Tree
console = Console() console = Console()
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None: def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
if not windows: columns = [
console.print("[yellow]No windows found[/yellow]") ("ID", lambda window: window.get("id", "")),
return ("Alias", lambda window: window.get("alias") or ""),
table = Table(show_header=True, header_style="bold cyan") ("Tabs", lambda window: window.get("tabCount", "")),
if show_browser: ("State", lambda window: window.get("state") or ""),
table.add_column("Browser") ]
table.add_column("ID", style="dim", no_wrap=True) print_browser_grouped_table_rows(windows, columns, console=console, empty_message="[yellow]No windows found[/yellow]")
table.add_column("Alias", width=20)
table.add_column("Tabs", width=6)
table.add_column("State", width=12)
for w in windows:
row = [
w.get("browser", "") if show_browser else None,
str(w.get("id", "")),
w.get("alias") or "",
str(w.get("tabCount", "")),
w.get("state") or "",
]
table.add_row(*[value for value in row if value is not None])
console.print(table)
@click.group("windows") @click.group("windows")
def windows_group(): def windows_group():
"""Manage browser windows.""" """Manage browser windows."""
@windows_group.command("list") @windows_group.command("list")
@handle_errors @handle_errors
def windows_list(): def windows_list():
"""List all browser windows.""" """List all browser windows."""
windows = client_from_ctx().windows.list() windows = client_from_ctx().windows.list()
_print_windows(windows, show_browser=any("browser" in w for w in windows)) _print_windows(windows, show_browser=any("browser" in w for w in windows))
@windows_group.command("tree") @windows_group.command("tree")
@handle_errors @handle_errors
def windows_tree(): def windows_tree():
"""Show windows and their tabs as a tree.""" """Show windows and their tabs as a tree."""
client = client_from_ctx() client = client_from_ctx()
windows = client.windows.list() root = build_windows_tree(client.windows.list(), client.tabs.list(), console=console)
tabs = client.tabs.list() print_tree(root, console=console)
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") @windows_group.command("rename")
@click.argument("window_id", type=int) @click.argument("window_id", type=int)
@click.argument("name") @click.argument("name")
@handle_errors @handle_errors
def windows_rename(window_id, name): def windows_rename(window_id, name):
"""Give a window a local alias NAME (stored in native host).""" """Give a window a local alias NAME (stored in native host)."""
client_from_ctx().windows.rename(window_id, name) client_from_ctx().windows.rename(window_id, name)
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]") console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
@windows_group.command("close") @windows_group.command("close")
@click.argument("window_id", type=int) @click.argument("window_id", type=int)
@handle_errors @handle_errors
def windows_close(window_id): def windows_close(window_id):
"""Close a browser window.""" """Close a browser window."""
client_from_ctx().windows.close(window_id) client_from_ctx().windows.close(window_id)
console.print(f"[green]Window {window_id} closed[/green]") console.print(f"[green]Window {window_id} closed[/green]")
@windows_group.command("open") @windows_group.command("open")
@click.argument("url", required=False) @click.argument("url", required=False)
@handle_errors @handle_errors
def windows_open(url): def windows_open(url):
"""Open a new browser window.""" """Open a new browser window."""
result = client_from_ctx().windows.open(url) result = client_from_ctx().windows.open(url)
wid = result.get("id") if isinstance(result, dict) else result 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 "")) console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
-20
View File
@@ -4,26 +4,6 @@ from __future__ import annotations
import re import re
from html.parser import HTMLParser from html.parser import HTMLParser
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()
class _HtmlNode: class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None): def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag self.tag = tag
+7
View File
@@ -31,6 +31,7 @@ class BrowserCounts:
"""Aggregated per-browser counts returned in implicit multi-browser mode.""" """Aggregated per-browser counts returned in implicit multi-browser mode."""
total: int total: int
by_browser: dict[str, int] by_browser: dict[str, int]
browser_groups: dict[str, str] = field(default_factory=dict)
# ── Tab ─────────────────────────────────────────────────────────────────────── # ── Tab ───────────────────────────────────────────────────────────────────────
@@ -44,7 +45,10 @@ class Tab:
title: str = "" title: str = ""
url: str = "" url: str = ""
group_id: int | None = None group_id: int | None = None
index: int = 0
browser: str | None = None browser: str | None = None
browser_name: str | None = None
browser_group: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser: def _b(self) -> BoundBrowser:
@@ -149,7 +153,10 @@ class Group:
color: str color: str
collapsed: bool collapsed: bool
tab_count: int tab_count: int
window_id: int | None = None
browser: str | None = None browser: str | None = None
browser_name: str | None = None
browser_group: str | None = None
_browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False) _browser: BoundBrowser | None = field(default=None, repr=False, compare=False, init=False)
def _b(self) -> BoundBrowser: def _b(self) -> BoundBrowser:
+145
View File
@@ -0,0 +1,145 @@
"""Challenge/response auth helpers for remote TCP transport."""
from __future__ import annotations
import asyncio
import json
import sys
from collections.abc import Callable
from typing import TypeVar
from browser_cli.errors import BrowserNotConnected
from browser_cli.version_manager import USER_AGENT
T = TypeVar("T")
AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
PQ_WARNING = (
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
def parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
try:
challenge = json.loads(raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
return challenge, nonce_hex
except (json.JSONDecodeError, AttributeError):
return None, None
def check_min_client_version(challenge: dict | None) -> None:
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if not min_ver:
return
from browser_cli.version_manager import parse_version
try:
client_ver = USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
def clean_message(msg: dict) -> dict:
return {key: value for key, value in msg.items() if key not in AUTH_FIELDS}
def get_pq_public_key(challenge: dict | None) -> str | None:
if not isinstance(challenge, dict):
return None
from browser_cli.auth import PQ_KEX_ALG
kex = challenge.get("pq_kex")
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
return str(kex["public_key"])
return None
def signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
from browser_cli.auth import pq_encrypt, public_key_hex, sign
nonce = bytes.fromhex(nonce_hex)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
pubkey = public_key_hex(private_key)
if pq_shared_secret is None:
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
return {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": pubkey,
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
def emit_no_pq_warning(enabled: bool) -> None:
if enabled:
sys.stderr.write(PQ_WARNING)
def build_auth_message(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
encapsulate: Callable[[str], tuple[str, bytes]],
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
if not nonce_hex or private_key is None:
emit_no_pq_warning(warn_no_pq)
return msg, None
clean_msg = clean_message(msg)
pq_shared_secret = None
pq_public_key = get_pq_public_key(challenge)
if pq_public_key:
from browser_cli.auth import PQ_KEX_ALG
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
emit_no_pq_warning(warn_no_pq)
return signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
async def build_auth_message_async(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
def encapsulate(public_key: str) -> tuple[str, bytes]:
from browser_cli.auth import pq_kex_client_encapsulate
return pq_kex_client_encapsulate(public_key)
return await asyncio.to_thread(
build_auth_message,
msg,
challenge,
nonce_hex,
private_key,
encapsulate,
warn_no_pq=warn_no_pq,
)
def decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
if response is None or pq_shared_secret is None:
return response
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as exc:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {exc}") from exc
return response
def with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
if challenge_raw is None:
raise BrowserNotConnected("No challenge received from remote endpoint")
challenge, nonce_hex = parse_challenge(challenge_raw)
check_min_client_version(challenge)
return build_auth(msg, challenge, nonce_hex, private_key)
def should_warn_no_pq(msg: dict) -> bool:
return not bool(msg.pop("_suppress_pq_warning", False))
+52
View File
@@ -0,0 +1,52 @@
"""Socket helpers for remote TCP/TLS transport."""
from __future__ import annotations
import asyncio
import socket
from contextlib import contextmanager
from browser_cli.endpoints import _resolve_connect_endpoint
from browser_cli.framing import async_recv_exact, async_recv_frame, recv_exact, recv_frame
def recv_exact_bytes(sock: socket.socket, n: int) -> bytes:
return recv_exact(sock, n) or b""
def recv_all(sock: socket.socket) -> bytes:
return recv_frame(sock, label="Response") or b""
async def async_recv_exact_bytes(reader: asyncio.StreamReader, n: int) -> bytes:
return await async_recv_exact(reader, n) or b""
async def async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader, label="Response") or b""
def split_endpoint(endpoint: str) -> tuple[str, int]:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
@contextmanager
def open_socket(endpoint: str):
host, port = split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
yield sock
async def open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host, port = split_endpoint(endpoint)
ssl_ctx = None
if port == 443:
import ssl
ssl_ctx = ssl.create_default_context()
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
+28 -199
View File
@@ -1,202 +1,43 @@
"""TCP/TLS transport for talking to a remote ``browser-cli serve``. """TCP/TLS transport for talking to a remote ``browser-cli serve``.
Owns the wire mechanics of the remote leg: open a socket (TLS on :443), This module keeps the public/private compatibility surface used by older tests
complete the signed challenge/response handshake with an optional post-quantum and callers, while delegating socket mechanics and auth-handshake details to
key exchange, frame the request, and read the framed (possibly encrypted) focused helper modules.
response. The higher-level "which endpoint / which profile / which key"
decisions stay in :mod:`browser_cli.client.core`.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import socket
import sys
from collections.abc import Callable
from contextlib import contextmanager
from typing import TypeVar
from browser_cli.errors import BrowserNotConnected from browser_cli.framing import async_send_frame, frame
from browser_cli.endpoints import _resolve_connect_endpoint from browser_cli.remote.auth import (
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame build_auth_message as _build_auth_message,
from browser_cli.version_manager import USER_AGENT as _USER_AGENT build_auth_message_async as _build_auth_message_async,
decode_pq_response as _decode_pq_response,
T = TypeVar("T") parse_challenge as _parse_challenge,
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"} should_warn_no_pq as _should_warn_no_pq,
_PQ_WARNING = ( with_challenge as _with_challenge,
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n" )
"** This session may be vulnerable to store now, decrypt later attacks.\n" from browser_cli.remote.socket import (
async_recv_all as _async_recv_all,
async_recv_exact_bytes as _async_recv_exact,
open_async_connection as _open_async_connection,
open_socket as _open_socket,
recv_all as _recv_all,
recv_exact_bytes as _recv_exact,
split_endpoint as _split_endpoint,
) )
def _recv_exact(sock: socket.socket, n: int) -> bytes: def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
return recv_exact(sock, n) or b"" warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def _recv_all(sock: socket.socket) -> bytes: def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
return recv_frame(sock, label="Response") or b""
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
return await async_recv_exact(reader, n) or b""
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
return await async_recv_frame(reader, label="Response") or b""
def _split_endpoint(endpoint: str) -> tuple[str, int]:
connect_ep = _resolve_connect_endpoint(endpoint)
host, _, port_str = connect_ep.rpartition(":")
return host, int(port_str)
@contextmanager
def _open_socket(endpoint: str):
host, port = _split_endpoint(endpoint)
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
raw_sock.settimeout(30)
try:
raw_sock.connect((host, port))
if port == 443:
import ssl
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
else:
sock = raw_sock
except Exception:
raw_sock.close()
raise
with sock:
yield sock
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
host, port = _split_endpoint(endpoint)
ssl_ctx = None
if port == 443:
import ssl
ssl_ctx = ssl.create_default_context()
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
try:
challenge = json.loads(raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
return challenge, nonce_hex
except (json.JSONDecodeError, AttributeError):
return None, None
def _check_min_client_version(challenge: dict | None) -> None:
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if not min_ver:
return
from browser_cli.version_manager import parse_version
try:
client_ver = _USER_AGENT.split("/", 1)[1]
if parse_version(client_ver) < parse_version(min_ver):
raise BrowserNotConnected(
f"Client version {client_ver} is too old for this server "
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
)
except (IndexError, ValueError):
pass
def _clean_message(msg: dict) -> dict:
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
def _get_pq_public_key(challenge: dict | None) -> str | None:
if not isinstance(challenge, dict):
return None
from browser_cli.auth import PQ_KEX_ALG
kex = challenge.get("pq_kex")
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
return str(kex["public_key"])
return None
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
nonce = bytes.fromhex(nonce_hex)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
pubkey = public_key_hex(private_key)
if pq_shared_secret is None:
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
return {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": pubkey,
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
def _warn_no_pq(enabled: bool) -> None:
if enabled:
sys.stderr.write(_PQ_WARNING)
def _build_auth_message(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
encapsulate: Callable[[str], tuple[str, bytes]],
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
if not nonce_hex or private_key is None:
_warn_no_pq(warn_no_pq)
return msg, None
clean_msg = _clean_message(msg)
pq_shared_secret = None
pq_public_key = _get_pq_public_key(challenge)
if pq_public_key:
from browser_cli.auth import PQ_KEX_ALG
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
_warn_no_pq(warn_no_pq)
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
async def _build_auth_message_async(
msg: dict,
challenge: dict | None,
nonce_hex: str | None,
private_key,
*,
warn_no_pq: bool = True,
) -> tuple[dict, bytes | None]:
def encapsulate(public_key: str) -> tuple[str, bytes]:
from browser_cli.auth import pq_kex_client_encapsulate from browser_cli.auth import pq_kex_client_encapsulate
return pq_kex_client_encapsulate(public_key) return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
return await asyncio.to_thread( with _open_socket(endpoint) as sock:
_build_auth_message, payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
msg, sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
challenge, return _decode_pq_response(_recv_all(sock), pq_shared_secret)
nonce_hex,
private_key,
encapsulate,
warn_no_pq=warn_no_pq,
)
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
if response is None or pq_shared_secret is None:
return response
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
def _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
if challenge_raw is None:
raise BrowserNotConnected("No challenge received from remote endpoint")
challenge, nonce_hex = _parse_challenge(challenge_raw)
_check_min_client_version(challenge)
return build_auth(msg, challenge, nonce_hex, private_key)
def _should_warn_no_pq(msg: dict) -> bool:
return not bool(msg.pop("_suppress_pq_warning", False))
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None: async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
reader, writer = await _open_async_connection(endpoint) reader, writer = await _open_async_connection(endpoint)
@@ -216,15 +57,3 @@ async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn
await writer.wait_closed() await writer.wait_closed()
except Exception: except Exception:
pass pass
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
from browser_cli.auth import pq_kex_client_encapsulate
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
with _open_socket(endpoint) as sock:
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
+3 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import Any, TypeVar from typing import Any, TypeVar, cast
F = TypeVar("F", bound=Callable) F = TypeVar("F", bound=Callable)
_MISSING = object() _MISSING = object()
@@ -54,8 +54,8 @@ def sdk_command(
return _clone_default(default) return _clone_default(default)
return result return result
wrapper._browser_cli_command = name # type: ignore[attr-defined] setattr(wrapper, "_browser_cli_command", name)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
+5 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import functools import functools
import inspect import inspect
from collections.abc import Callable from collections.abc import Callable
from typing import TypeVar from typing import TypeVar, cast
from browser_cli.sdk.base import Namespace from browser_cli.sdk.base import Namespace
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
@@ -53,7 +53,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally: finally:
if cleanup is not None: if cleanup is not None:
await asyncio.to_thread(cleanup, value) await asyncio.to_thread(cleanup, value)
return async_wrapper # type: ignore[return-value] return cast(F, async_wrapper)
return WorkflowDecoratorsMixin._value_decorator( return WorkflowDecoratorsMixin._value_decorator(
self, fn, get_value, keyword=keyword, cleanup=cleanup self, fn, get_value, keyword=keyword, cleanup=cleanup
) )
@@ -74,7 +74,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
finally: finally:
if previous: if previous:
await asyncio.to_thread(self._c.perf.set_profile, previous) await asyncio.to_thread(self._c.perf.set_profile, previous)
return async_wrapper # type: ignore[return-value] return cast(F, async_wrapper)
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn) return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
return decorator return decorator
@@ -101,7 +101,7 @@ class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
raise raise
if delay > 0: if delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return async_wrapper # type: ignore[return-value] return cast(F, async_wrapper)
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn) return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
return decorator return decorator
+101 -80
View File
@@ -11,93 +11,114 @@ from typing import Any, Protocol, cast
from browser_cli.models import Group, Tab from browser_cli.models import Group, Tab
def _target_group(target) -> str | None:
if target is None:
return None
return getattr(target, "display_group", None) or ("local" if target.remote is None else None)
class _FactoryClient(Protocol): class _FactoryClient(Protocol):
_key: str | None _key: str | None
class FactoryMixin: class FactoryMixin:
"""Turn raw response dicts into bound ``Tab``/``Group`` objects. """Turn raw response dicts into bound ``Tab``/``Group`` objects.
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``. ``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
""" """
def tab_from( def tab_from(
self, self,
data: dict, data: dict,
*, *,
browser_profile: str | None = None, browser_profile: str | None = None,
browser_name: str | None = None, browser_name: str | None = None,
browser_remote: str | None = None, browser_remote: str | None = None,
) -> Tab: browser_type: str | None = None,
tab = Tab( browser_group: str | None = None,
id=data["id"], ) -> Tab:
window_id=data.get("windowId", 0), tab = Tab(
active=data.get("active", False), id=data["id"],
muted=data.get("muted", False), window_id=data.get("windowId", 0),
title=data.get("title") or "", active=data.get("active", False),
url=data.get("url") or "", muted=data.get("muted", False),
group_id=data.get("groupId") or None, title=data.get("title") or "",
browser=browser_name, url=data.get("url") or "",
) group_id=data.get("groupId") or None,
client = cast(_FactoryClient, self) index=data.get("index", 0) or 0,
tab._browser = self if browser_profile is None else cast(Any, type(self))( browser=browser_name,
browser=browser_profile, browser_name=browser_type,
remote=browser_remote, browser_group=browser_group,
key=client._key, )
_command_sender=getattr(self, "_command_sender", None), client = cast(_FactoryClient, self)
) tab._browser = self if browser_profile is None else cast(Any, type(self))(
return tab browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return tab
def require_tab_response(self, data, error: str) -> Tab: def require_tab_response(self, data, error: str) -> Tab:
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``.""" """Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
if not isinstance(data, dict) or "id" not in data: if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(error) raise RuntimeError(error)
return self.tab_from(data) return self.tab_from(data)
def group_from( def group_from(
self, self,
data: dict, data: dict,
*, *,
browser_profile: str | None = None, browser_profile: str | None = None,
browser_name: str | None = None, browser_name: str | None = None,
browser_remote: str | None = None, browser_remote: str | None = None,
) -> Group: browser_type: str | None = None,
group = Group( browser_group: str | None = None,
id=data["id"], ) -> Group:
title=data.get("title") or "", group = Group(
color=data.get("color") or "", id=data["id"],
collapsed=data.get("collapsed", False), title=data.get("title") or "",
tab_count=data.get("tabCount", 0), color=data.get("color") or "",
browser=browser_name, collapsed=data.get("collapsed", False),
) tab_count=data.get("tabCount", 0),
client = cast(_FactoryClient, self) window_id=data.get("windowId"),
group._browser = self if browser_profile is None else cast(Any, type(self))( browser=browser_name,
browser=browser_profile, browser_name=browser_type,
remote=browser_remote, browser_group=browser_group,
key=client._key, )
_command_sender=getattr(self, "_command_sender", None), client = cast(_FactoryClient, self)
) group._browser = self if browser_profile is None else cast(Any, type(self))(
return group browser=browser_profile,
remote=browser_remote,
key=client._key,
_command_sender=getattr(self, "_command_sender", None),
)
return group
def tab_from_target(self, data: dict, target) -> Tab: def tab_from_target(self, data: dict, target) -> Tab:
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local).""" """Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.tab_from( return self.tab_from(
data, data,
browser_profile=target.profile if target else None, browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None, browser_remote=target.remote if target else None,
) browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
def group_from_target(self, data: dict, target) -> Group: def group_from_target(self, data: dict, target) -> Group:
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local).""" """Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
return self.group_from( return self.group_from(
data, data,
browser_profile=target.profile if target else None, browser_profile=target.profile if target else None,
browser_name=target.display_name if target else None, browser_name=target.display_name if target else None,
browser_remote=target.remote if target else None, browser_remote=target.remote if target else None,
) browser_type=getattr(target, "browser_name", None) if target else None,
browser_group=_target_group(target),
)
@staticmethod @staticmethod
def tag_browser(item: dict, target) -> dict: def tag_browser(item: dict, target) -> dict:
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode.""" """Return *item* as-is locally, or with browser metadata in multi-browser mode."""
return item if target is None else {**item, "browser": target.display_name} if target is None:
return item
return {**item, "browser": target.display_name, "browserGroup": _target_group(target)}
+28 -4
View File
@@ -37,6 +37,20 @@ _UNSET = object()
def _browser_cli_package(): def _browser_cli_package():
return sys.modules.get("browser_cli") or importlib.import_module("browser_cli") return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
def _with_profile_display(targets: list[BrowserTarget]) -> list[BrowserTarget]:
"""Use profile-only labels when a command is already scoped to one remote."""
return [
BrowserTarget(
profile=target.profile,
display_name=target.profile if target.remote else target.display_name,
socket_path=target.socket_path,
remote=target.remote,
browser_name=target.browser_name,
display_group=None,
)
for target in targets
]
class RoutingMixin: class RoutingMixin:
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``. """Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
@@ -51,10 +65,15 @@ class RoutingMixin:
def _multi_browser_targets(self) -> list[BrowserTarget]: def _multi_browser_targets(self) -> list[BrowserTarget]:
client = self._client client = self._client
package = _browser_cli_package() package = _browser_cli_package()
if client._browser is not None: if client._browser is not None and not client._remote:
targets = package.remote_targets_for_alias(client._browser, key=client._key)
if len(targets) <= 1:
return []
targets = _with_profile_display(targets)
elif client._browser is not None:
return [] return []
if client._remote: elif client._remote:
targets = package.remote_browser_targets(client._remote, key=client._key) targets = _with_profile_display(package.remote_browser_targets(client._remote, key=client._key))
else: else:
targets = package.active_browser_targets() targets = package.active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets): if len(targets) <= 1 and not any(target.remote for target in targets):
@@ -107,7 +126,12 @@ class RoutingMixin:
if not multi_results: if not multi_results:
return self._client.dispatch(command, args or {}) return self._client.dispatch(command, args or {})
by_browser = {target.display_name: int(count or 0) for target, count in multi_results} by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser) browser_groups = {
target.display_name: target.display_group or "local"
for target, _count in multi_results
if target.display_group or target.remote is None
}
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser, browser_groups=browser_groups)
def multi_list(self, command: str, args: dict | None, mapper): def multi_list(self, command: str, args: dict | None, mapper):
"""List command, flattening per-browser results in multi-browser mode. """List command, flattening per-browser results in multi-browser mode.
+19
View File
@@ -6,6 +6,11 @@ from collections.abc import Callable, Iterable
from browser_cli.models import BrowserCounts, Tab from browser_cli.models import BrowserCounts, Tab
from browser_cli.sdk.base import Namespace from browser_cli.sdk.base import Namespace
# Keep SDK-driven bulk closes comfortably below the native-host response
# timeout. The extension can close larger batches, but real browsers may take
# much longer when hundreds of visible tabs are involved.
BULK_CLOSE_CHUNK_SIZE = 50
class TabsNS(Namespace): class TabsNS(Namespace):
"""List, open, close, move, and inspect browser tabs.""" """List, open, close, move, and inspect browser tabs."""
@@ -75,6 +80,20 @@ class TabsNS(Namespace):
ids = None ids = None
if tab_ids is not None: if tab_ids is not None:
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids] ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
if ids is not None and len(ids) > BULK_CLOSE_CHUNK_SIZE and not inactive and not duplicates and tab_id is None:
closed = 0
for start in range(0, len(ids), BULK_CLOSE_CHUNK_SIZE):
chunk = ids[start:start + BULK_CLOSE_CHUNK_SIZE]
result = self.command("tabs.close", {
"tabId": None,
"tabIds": chunk,
"inactive": False,
"duplicates": False,
"gentleMode": gentle_mode,
})
closed += self.field(result, "closed", len(chunk))
return closed
result = self.command("tabs.close", { result = self.command("tabs.close", {
"tabId": tab_id, "tabId": tab_id,
"tabIds": ids, "tabIds": ids,
+35 -14
View File
@@ -4,11 +4,32 @@ from __future__ import annotations
import functools import functools
import time import time
from collections.abc import Callable from collections.abc import Callable
from typing import TypeVar from typing import Protocol, TypeVar, cast
F = TypeVar("F", bound=Callable) F = TypeVar("F", bound=Callable)
_NO_INJECT = object() _NO_INJECT = object()
class _WorkflowTabs(Protocol):
def active(self): ...
def open(self, *args, **kwargs): ...
def watch_url(self, *args, **kwargs): ...
class _WorkflowDom(Protocol):
def wait_for(self, *args, **kwargs): ...
class _WorkflowPerf(Protocol):
def status(self): ...
def set_profile(self, profile: str): ...
class _WorkflowSession(Protocol):
def save(self, name: str): ...
class _WorkflowClient(Protocol):
tabs: _WorkflowTabs
dom: _WorkflowDom
perf: _WorkflowPerf
session: _WorkflowSession
class WorkflowDecoratorsMixin: class WorkflowDecoratorsMixin:
"""Shared implementation for sync and async workflow decorators. """Shared implementation for sync and async workflow decorators.
@@ -17,7 +38,7 @@ class WorkflowDecoratorsMixin:
in lockstep. in lockstep.
""" """
_c: object _c: _WorkflowClient
@staticmethod @staticmethod
def _inject(kwargs: dict, keyword: str | None, value): def _inject(kwargs: dict, keyword: str | None, value):
@@ -62,7 +83,7 @@ class WorkflowDecoratorsMixin:
finally: finally:
if cleanup is not None: if cleanup is not None:
self._run(cleanup, value) self._run(cleanup, value)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator(func) if func is not None else decorator return decorator(func) if func is not None else decorator
@@ -72,7 +93,7 @@ class WorkflowDecoratorsMixin:
By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to By default the tab is injected as ``tab=...``. Pass ``keyword=None`` to
pass it as the first positional argument instead. pass it as the first positional argument instead.
""" """
return self._value_decorator(func, self._c.tabs.active, keyword=keyword) # type: ignore[attr-defined] return self._value_decorator(func, self._c.tabs.active, keyword=keyword)
def new_tab( def new_tab(
self, self,
@@ -93,7 +114,7 @@ class WorkflowDecoratorsMixin:
wrapped function returns or raises. wrapped function returns or raises.
""" """
def open_tab(): def open_tab():
return self._c.tabs.open( # type: ignore[attr-defined] return self._c.tabs.open(
url, url,
wait=wait, wait=wait,
timeout=timeout, timeout=timeout,
@@ -124,7 +145,7 @@ class WorkflowDecoratorsMixin:
the wrapped function. By default the result is not injected. the wrapped function. By default the result is not injected.
""" """
def wait(): def wait():
return self._c.dom.wait_for( # type: ignore[attr-defined] return self._c.dom.wait_for(
selector, selector,
timeout=timeout, timeout=timeout,
visible=visible, visible=visible,
@@ -145,7 +166,7 @@ class WorkflowDecoratorsMixin:
): ):
"""Wait until a tab URL matches *pattern* before calling the function.""" """Wait until a tab URL matches *pattern* before calling the function."""
def wait(): def wait():
return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout) # type: ignore[attr-defined] return self._c.tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
inject = keyword if keyword is not None else _NO_INJECT inject = keyword if keyword is not None else _NO_INJECT
return self._value_decorator(None, wait, keyword=inject) return self._value_decorator(None, wait, keyword=inject)
@@ -157,19 +178,19 @@ class WorkflowDecoratorsMixin:
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
previous = None previous = None
if restore: if restore:
previous = self._run(self._c.perf.status).get("performanceProfile") # type: ignore[attr-defined] previous = self._run(self._c.perf.status).get("performanceProfile")
self._run(self._c.perf.set_profile, profile) # type: ignore[attr-defined] self._run(self._c.perf.set_profile, profile)
try: try:
return self._call_wrapped(fn, *args, **kwargs) return self._call_wrapped(fn, *args, **kwargs)
finally: finally:
if previous: if previous:
self._run(self._c.perf.set_profile, previous) # type: ignore[attr-defined] self._run(self._c.perf.set_profile, previous)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
def save_session_before(self, name: str): def save_session_before(self, name: str):
"""Save the current browser session before running the function.""" """Save the current browser session before running the function."""
return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT) # type: ignore[attr-defined] return self._value_decorator(None, lambda: self._c.session.save(name), keyword=_NO_INJECT)
def retry( def retry(
self, self,
@@ -194,7 +215,7 @@ class WorkflowDecoratorsMixin:
raise raise
if delay > 0: if delay > 0:
self._sleep(delay) self._sleep(delay)
raise last_error # type: ignore[misc] raise cast(BaseException, last_error)
return wrapper # type: ignore[return-value] return cast(F, wrapper)
return decorator return decorator
+31
View File
@@ -0,0 +1,31 @@
"""Challenge-frame helpers for ``browser-cli serve``."""
from __future__ import annotations
import asyncio
import secrets
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
async def load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
if auth_keys_path is None:
return None
from browser_cli.auth import load_authorized_keys
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
async def build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
return nonce, pq_private_key, challenge_msg
+13 -5
View File
@@ -16,11 +16,19 @@ class ServeControlMixin:
async def handle_control_command(self, msg: dict) -> bool: async def handle_control_command(self, msg: dict) -> bool:
if self.command == "browser-cli.targets": if self.command == "browser-cli.targets":
from browser_cli.client import active_browser_targets from browser_cli.client import active_browser_targets, send_command
targets = [ targets = []
{"profile": target.profile, "displayName": target.display_name} for target in active_browser_targets(include_remotes=False):
for target in active_browser_targets(include_remotes=False) item = {"profile": target.profile, "displayName": target.display_name}
] try:
clients = send_command("clients.list", profile=target.profile, suppress_pq_warning=True)
if clients:
browser_name = clients[0].get("name")
if browser_name:
item["browserName"] = browser_name
except Exception:
pass
targets.append(item)
await self.send_ok(targets, self.command) await self.send_ok(targets, self.command)
log_request(self.addr, self.command, None, "OK") log_request(self.addr, self.command, None, "OK")
return True return True
+1 -25
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import secrets
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -17,10 +16,10 @@ from browser_cli import transport
from browser_cli.compat import adapt_auth from browser_cli.compat import adapt_auth
from browser_cli.framing import async_recv_frame, async_send_frame from browser_cli.framing import async_recv_frame, async_send_frame
from browser_cli.serve.auth import ServeAuthMixin from browser_cli.serve.auth import ServeAuthMixin
from browser_cli.serve.challenge import build_challenge as _build_challenge, load_auth_keys as _load_auth_keys
from browser_cli.serve.control import ServeControlMixin from browser_cli.serve.control import ServeControlMixin
from browser_cli.serve.logging import console, log_request from browser_cli.serve.logging import console, log_request
from browser_cli.serve.proxy import ServeProxyMixin from browser_cli.serve.proxy import ServeProxyMixin
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None: async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
await async_send_frame(writer, data) await async_send_frame(writer, data)
@@ -140,29 +139,6 @@ async def _async_handle_client(
except Exception: except Exception:
pass pass
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
if auth_keys_path is None:
return None
from browser_cli.auth import load_authorized_keys
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = await asyncio.to_thread(pq_kex_server_keypair)
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
return nonce, pq_private_key, challenge_msg
def _handle_client( def _handle_client(
client_sock: socket.socket, client_sock: socket.socket,
addr: tuple, addr: tuple,
-214
View File
@@ -1,214 +0,0 @@
"""Response payload encoding for the TCP serve <-> client leg.
The wire frame stays ``4-byte LE length + payload``. The payload is made
self-describing so old peers keep working unchanged:
* A payload that starts with ``{`` or ``[`` is plain JSON (the historical
format). Old clients and old servers only ever produce/consume this.
* Any other leading byte is a 1-byte codec tag followed by the encoded body.
The tag's high nibble selects serialization, the low nibble compression::
tag = (serialization << 4) | compression
This is only ever emitted toward a peer that advertised support for it, so it
is fully backward compatible: clients announce what they can decode via the
``accept_encoding`` field in their request, and the server encodes the
response accordingly. Requests themselves stay plain JSON (they are tiny).
Compression is the big win response payloads (``extract.html``,
``dom.query``, ``tabs.list`` over hundreds of tabs, base64 screenshots) are
heavy and text-like. msgpack additionally lets ``tabs.screenshot`` ship the
image as raw bytes instead of a base64 data URL (~33% smaller before
compression); the client transparently rebuilds the data URL so the SDK/CLI
API is unchanged.
"""
from __future__ import annotations
import base64
import gzip
import json
import re
import zlib
from browser_cli.constants import (
COMP_GZIP,
COMP_NONE,
COMP_ZLIB,
COMP_ZSTD,
DEFAULT_TRANSPORT_THRESHOLD,
SER_JSON,
SER_MSGPACK,
)
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
# ── codec ids ────────────────────────────────────────────────────────────────
_SER_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
_SER_ID = {v: k for k, v in _SER_NAME.items()}
_COMP_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
_COMP_ID = {v: k for k, v in _COMP_NAME.items()}
# Don't compress payloads smaller than this — the header/CPU cost is not worth it.
# JSON top-level values always start with one of these bytes; a tag byte never does.
_JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
# ── compression primitives ────────────────────────────────────────────────────
def _compress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def _decompress(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
# ── codec negotiation ──────────────────────────────────────────────────────────
def _choose(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
ser = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
ser = SER_MSGPACK
comp = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
comp = _COMP_ID[name]
break
return ser, comp
# ── raw-binary hoisting (screenshots) ──────────────────────────────────────────
_DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
_B64_MARKER = "__b64__"
def _hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so we never touch arbitrary page-derived data.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
m = _DATA_URL_RE.match(url)
if not m:
return obj
try:
raw = base64.b64decode(m.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {_B64_MARKER: True, "mime": m.group(1), "raw": raw}
return {**obj, "data": new_data}
def _unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(_B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {k: _unhoist_binary(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_unhoist_binary(v) for v in obj]
return obj
# ── encode / decode ─────────────────────────────────────────────────────────────
def encode_response(obj, accept: dict | None = None, command: str | None = None,
threshold: int = DEFAULT_TRANSPORT_THRESHOLD) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+72
View File
@@ -0,0 +1,72 @@
"""Response payload encoding for the TCP serve <-> client leg.
The wire frame stays ``4-byte LE length + payload``. Payloads are plain JSON
for legacy peers, or a 1-byte codec tag followed by serialized/compressed data
when the peer advertised support for it.
"""
from __future__ import annotations
import json
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, DEFAULT_TRANSPORT_THRESHOLD, SER_JSON, SER_MSGPACK
from browser_cli.transport.binary import hoist_screenshot as _hoist_screenshot, unhoist_binary as _unhoist_binary
from browser_cli.transport.codecs import (
JSON_FIRST_BYTES as _JSON_FIRST_BYTES,
_msgpack,
choose_codec as _choose,
client_accept_encoding,
compress_payload as _compress,
decompress_payload as _decompress,
msgpack_available,
supported_compression,
supported_serialization,
zstd_available,
)
def encode_response(
obj,
accept: dict | None = None,
command: str | None = None,
threshold: int = DEFAULT_TRANSPORT_THRESHOLD,
) -> bytes:
"""Encode a response object for the chosen/accepted codec.
Returns bare JSON bytes when no encoding is negotiated, which is byte-for-byte
what an old server would have sent.
"""
ser, comp = _choose(accept)
if ser == SER_MSGPACK:
body = _msgpack.packb(_hoist_screenshot(obj, command), use_bin_type=True)
else:
body = json.dumps(obj).encode("utf-8")
if comp != COMP_NONE and len(body) >= threshold:
body = _compress(comp, body)
else:
comp = COMP_NONE
if ser == SER_JSON and comp == COMP_NONE:
return body # plain JSON — historical wire format, no tag byte
return bytes([(ser << 4) | comp]) + body
def decode_response(raw: bytes | None):
"""Decode a payload produced by :func:`encode_response` (or plain JSON)."""
if raw is None:
return None
if not raw:
raise ValueError("empty response payload")
if raw[0] in _JSON_FIRST_BYTES:
return json.loads(raw)
tag = raw[0]
ser, comp = tag >> 4, tag & 0x0F
body = _decompress(comp, raw[1:])
if ser == SER_MSGPACK:
if _msgpack is None:
raise ValueError("msgpack payload received but msgpack is not installed")
return _unhoist_binary(_msgpack.unpackb(body, raw=False))
if ser == SER_JSON:
return json.loads(body)
raise ValueError(f"unknown serialization id {ser}")
+44
View File
@@ -0,0 +1,44 @@
"""Raw-binary hoisting helpers for encoded response payloads."""
from __future__ import annotations
import base64
import re
DATA_URL_RE = re.compile(r"^data:([^;,]+);base64,(.+)$", re.S)
B64_MARKER = "__b64__"
def hoist_screenshot(obj, command: str | None):
"""Replace a screenshot data URL with raw bytes so msgpack ships it unencoded.
Gated to ``tabs.screenshot`` so arbitrary page-derived data is never touched.
"""
if command != "tabs.screenshot" or not isinstance(obj, dict):
return obj
data = obj.get("data")
if not isinstance(data, dict):
return obj
url = data.get("dataUrl")
if not isinstance(url, str):
return obj
match = DATA_URL_RE.match(url)
if not match:
return obj
try:
raw = base64.b64decode(match.group(2))
except Exception:
return obj
new_data = dict(data)
new_data["dataUrl"] = {B64_MARKER: True, "mime": match.group(1), "raw": raw}
return {**obj, "data": new_data}
def unhoist_binary(obj):
"""Rebuild any hoisted data URL so callers see the original string again."""
if isinstance(obj, dict):
raw = obj.get("raw")
if obj.get(B64_MARKER) and isinstance(raw, (bytes, bytearray)):
mime = obj.get("mime") or "application/octet-stream"
return f"data:{mime};base64," + base64.b64encode(bytes(raw)).decode("ascii")
return {key: unhoist_binary(value) for key, value in obj.items()}
if isinstance(obj, list):
return [unhoist_binary(value) for value in obj]
return obj
+84
View File
@@ -0,0 +1,84 @@
"""Serialization/compression primitives for TCP response payloads."""
from __future__ import annotations
import gzip
import zlib
from browser_cli.constants import COMP_GZIP, COMP_NONE, COMP_ZLIB, COMP_ZSTD, SER_JSON, SER_MSGPACK
try: # optional: better ratio + speed than zlib/gzip
import zstandard as _zstd
except Exception: # pragma: no cover - depends on optional extra
_zstd = None
try: # optional: alternate serialization + raw binary for screenshots
import msgpack as _msgpack
except Exception: # pragma: no cover - depends on optional extra
_msgpack = None
SERIALIZATION_NAME = {SER_JSON: "json", SER_MSGPACK: "msgpack"}
SERIALIZATION_ID = {value: key for key, value in SERIALIZATION_NAME.items()}
COMPRESSION_NAME = {COMP_NONE: "none", COMP_ZLIB: "zlib", COMP_GZIP: "gzip", COMP_ZSTD: "zstd"}
COMPRESSION_ID = {value: key for key, value in COMPRESSION_NAME.items()}
JSON_FIRST_BYTES = frozenset(b"{[")
def msgpack_available() -> bool:
return _msgpack is not None
def zstd_available() -> bool:
return _zstd is not None
def supported_serialization() -> list[str]:
"""Serializations this build can produce/consume, best first."""
return (["msgpack"] if _msgpack is not None else []) + ["json"]
def supported_compression() -> list[str]:
"""Compression codecs this build can produce/consume, best first."""
return (["zstd"] if _zstd is not None else []) + ["gzip", "zlib"]
def client_accept_encoding() -> dict:
"""What the local client advertises it can decode (sent with each request)."""
return {"ser": supported_serialization(), "comp": supported_compression()}
def compress_payload(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.compress(data, 6)
if comp_id == COMP_GZIP:
return gzip.compress(data, compresslevel=6)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd compression requested but zstandard is not installed")
return _zstd.ZstdCompressor(level=10).compress(data)
raise ValueError(f"unknown compression id {comp_id}")
def decompress_payload(comp_id: int, data: bytes) -> bytes:
if comp_id == COMP_NONE:
return data
if comp_id == COMP_ZLIB:
return zlib.decompress(data)
if comp_id == COMP_GZIP:
return gzip.decompress(data)
if comp_id == COMP_ZSTD:
if _zstd is None:
raise ValueError("zstd payload received but zstandard is not installed")
return _zstd.ZstdDecompressor().decompress(data)
raise ValueError(f"unknown compression id {comp_id}")
def choose_codec(accept: dict | None) -> tuple[int, int]:
"""Pick (serialization_id, compression_id) the peer accepts, server preference first."""
accept = accept if isinstance(accept, dict) else {}
accept_ser = accept.get("ser") or ["json"]
accept_comp = accept.get("comp") or []
serialization = SER_JSON
if _msgpack is not None and "msgpack" in accept_ser:
serialization = SER_MSGPACK
compression = COMP_NONE
for name in supported_compression(): # server preference: zstd > gzip > zlib
if name in accept_comp:
compression = COMPRESSION_ID[name]
break
return serialization, compression
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.15.2", "version": "0.15.6",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
-404
View File
@@ -1,404 +0,0 @@
import type { ContentArgs } from '../types';
export function extractMarkdown({ selector }: ContentArgs) {
const BLOCKS = new Set([
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
"th", "thead", "tr", "ul"
]);
const NOISE_SELECTOR = [
"script",
"style",
"noscript",
"template",
"svg",
"canvas",
"iframe",
"dialog",
"button",
"input",
"textarea",
"select",
"option",
"form",
"[hidden]",
"[aria-hidden='true']",
".sr-only",
"[class*='sr-only']",
"[class*='file-tile']",
"form[data-type='unified-composer']",
".composer-btn",
"[data-composer-surface='true']",
"#thread-bottom-container",
"[data-testid*='action-button']",
].join(", ");
function normalizeText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeInline(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
function collapseBlankLines(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function escapeMarkdown(text: string) {
return text.replace(/([\\`[\]])/g, "\\$1");
}
function escapeTableCell(text: string) {
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function absoluteUrl(attr: string | null | undefined, fallback?: string) {
return attr || fallback || "";
}
function isNoiseElement(node: Node | null): boolean {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
if (el.hasAttribute("hidden")) return true;
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
if (el.matches(".sr-only, [class*='sr-only']")) return true;
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
if (el.matches("[data-testid*='action-button']")) return true;
return false;
}
function stripNoise(root: Element): Element {
const clone = root.cloneNode(true) as Element;
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
return clone;
}
function candidateScore(node: Element) {
const text = normalizeText((node as HTMLElement).innerText || "");
if (!text) return -Infinity;
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
const paragraphs = node.querySelectorAll("p").length;
const listItems = node.querySelectorAll("li").length;
const tables = node.querySelectorAll("table").length;
const codeBlocks = node.querySelectorAll("pre, code").length;
const images = node.querySelectorAll("img, figure").length;
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
const buttons = node.querySelectorAll("button, input, textarea, select").length;
const forms = node.querySelectorAll("form").length;
const svgs = node.querySelectorAll("svg, canvas").length;
return text.length
+ (mainLike * 4000)
+ (proseBlocks * 5000)
+ (headings * 250)
+ (paragraphs * 60)
+ (listItems * 35)
+ (tables * 80)
+ (codeBlocks * 60)
+ (images * 25)
- (buttons * 120)
- (forms * 200)
- (svgs * 40);
}
function pickRoot() {
if (selector) {
const matched = document.querySelector(selector);
if (!matched) throw new Error(`No element: ${selector}`);
return matched;
}
const candidates = Array.from(document.querySelectorAll(
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
))
.filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0);
if (!candidates.length) return document.body;
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
return candidates[0];
}
function inlineText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeMarkdown(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "br") return "\n";
if (tag === "img") {
const img = el as HTMLImageElement;
const src = absoluteUrl(img.getAttribute("src"), img.src);
if (!src) return "";
const alt = normalizeText(img.getAttribute("alt") || "");
return alt ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`;
}
if (tag === "a") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
if (!href) return text;
return `[${text || href}](${href})`;
}
if (tag === "code") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
}
if (tag === "strong" || tag === "b") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `**${text}**` : "";
}
if (tag === "em" || tag === "i") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `*${text}*` : "";
}
const chunks: string[] = [];
for (const child of el.childNodes) {
const rendered = inlineText(child);
if (!rendered) continue;
chunks.push(rendered);
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has((child as Element).tagName.toLowerCase())) {
chunks.push("\n");
}
}
return chunks.join("");
}
function textBlock(node: Node): string {
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
}
function preserveNodeText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "br") return "\n";
const parts: string[] = [];
for (const child of el.childNodes) {
const rendered = preserveNodeText(child);
if (!rendered) continue;
parts.push(rendered);
}
if (["div", "p", "li"].includes(tag)) {
return `${parts.join("")}\n`;
}
return parts.join("");
}
function repairFlattenedDiagram(text: string): string {
if (text.includes("\n")) return text;
const markerCount = (text.match(/[│▼├└]/g) || []).length;
if (markerCount < 2) return text;
let repaired = text;
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
return repaired
.split("\n")
.map(line => line.replace(/\s+$/, ""))
.filter(line => line.trim())
.join("\n");
}
function convertDashListsToBranches(lines: string[]): string[] {
const converted: string[] = [];
let index = 0;
while (index < lines.length) {
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
if (!match) {
converted.push(lines[index]);
index += 1;
continue;
}
const indent = match[1];
const items = [];
while (index < lines.length) {
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
if (!nextMatch) break;
items.push(nextMatch[1]);
index += 1;
}
items.forEach((item, itemIndex) => {
const branch = itemIndex === items.length - 1 ? "└" : "├";
converted.push(`${indent}${branch} ${item}`);
});
}
return converted;
}
function normalizeCodeBlock(text: string): string {
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
const flattened = repairFlattenedDiagram(lines.join("\n"));
lines = flattened ? flattened.split("\n") : [];
lines = lines.map(line => {
const trimmed = line.trim();
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
return ` ${trimmed}`;
}
return line;
});
lines = convertDashListsToBranches(lines);
return lines.join("\n");
}
function tableToMarkdown(table: Element) {
const rows = Array.from(table.querySelectorAll("tr"))
.map(row => Array.from(row.children)
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
.map(cell => escapeTableCell(textBlock(cell)))
)
.filter(cells => cells.length > 0);
if (!rows.length) return "";
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
const normalizedRows = rows.map(row => {
const next = row.slice();
while (next.length < widths) next.push("");
return next;
});
let headers = normalizedRows[0];
let bodyRows = normalizedRows.slice(1);
const firstRowIsBlank = headers.every(cell => !cell.trim());
if (firstRowIsBlank && normalizedRows.length > 1) {
headers = normalizedRows[1];
bodyRows = normalizedRows.slice(2);
}
const firstRow = table.querySelector("tr");
const thead = table.querySelector("thead");
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
headers = new Array(widths).fill("");
bodyRows = normalizedRows;
}
const separator = new Array(widths).fill("---");
const lines = [
`| ${headers.join(" | ")} |`,
`| ${separator.join(" | ")} |`,
];
for (const row of bodyRows) {
lines.push(`| ${row.join(" | ")} |`);
}
return lines.join("\n");
}
function listToMarkdown(list: Element, depth = 0): string {
const ordered = list.tagName.toLowerCase() === "ol";
const items: string[] = [];
const children = Array.from(list.children).filter(child => child.tagName === "LI");
children.forEach((item, index) => {
const marker = ordered ? `${index + 1}. ` : "- ";
const indent = " ".repeat(depth);
const nested: string[] = [];
const content: string[] = [];
for (const child of item.childNodes) {
const childEl = child as Element;
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
nested.push(listToMarkdown(childEl, depth + 1));
} else {
content.push(inlineText(child));
}
}
const line = collapseBlankLines(normalizeInline(content.join("")));
if (line) {
const lineParts = line.split("\n");
items.push(`${indent}${marker}${lineParts[0]}`);
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
}
nested.filter(Boolean).forEach(block => items.push(block));
});
return items.join("\n");
}
function blockToMarkdown(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeText(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "table") return tableToMarkdown(el);
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => {
const text = preserveNodeText(line);
return text === "\n" ? "" : text.replace(/\n$/, "");
});
const code = normalizeCodeBlock(lines.join("\n"));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "pre") {
const code = normalizeCodeBlock(preserveNodeText(el));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "blockquote") {
const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n"));
return content
.split("\n")
.map(line => line ? `> ${line}` : ">")
.join("\n");
}
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag.slice(1));
const text = textBlock(el);
return text ? `${"#".repeat(level)} ${text}` : "";
}
if (tag === "p" || tag === "figcaption") {
return textBlock(el);
}
if (tag === "hr") {
return "---";
}
if (tag === "img") {
return inlineText(el);
}
const childBlocks = Array.from(el.childNodes)
.map(child => blockToMarkdown(child))
.filter(Boolean);
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
return textBlock(node);
}
const root = stripNoise(pickRoot());
const markdown = blockToMarkdown(root);
return collapseBlankLines(markdown);
}
+63
View File
@@ -0,0 +1,63 @@
function repairFlattenedDiagram(text: string): string {
if (text.includes("\n")) return text;
const markerCount = (text.match(/[│▼├└]/g) || []).length;
if (markerCount < 2) return text;
let repaired = text;
repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1");
repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n");
repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n");
repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1");
repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2");
return repaired
.split("\n")
.map(line => line.replace(/\s+$/, ""))
.filter(line => line.trim())
.join("\n");
}
function convertDashListsToBranches(lines: string[]): string[] {
const converted: string[] = [];
let index = 0;
while (index < lines.length) {
const match = lines[index].match(/^(\s*)-\s+(.*)$/);
if (!match) {
converted.push(lines[index]);
index += 1;
continue;
}
const indent = match[1];
const items = [];
while (index < lines.length) {
const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`));
if (!nextMatch) break;
items.push(nextMatch[1]);
index += 1;
}
items.forEach((item, itemIndex) => {
const branch = itemIndex === items.length - 1 ? "└" : "├";
converted.push(`${indent}${branch} ${item}`);
});
}
return converted;
}
export function normalizeCodeBlock(text: string): string {
let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, ""));
while (lines.length && !lines[0].trim()) lines.shift();
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
const flattened = repairFlattenedDiagram(lines.join("\n"));
lines = flattened ? flattened.split("\n") : [];
lines = lines.map(line => {
const trimmed = line.trim();
if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) {
return ` ${trimmed}`;
}
return line;
});
lines = convertDashListsToBranches(lines);
return lines.join("\n");
}
+9
View File
@@ -0,0 +1,9 @@
import type { ContentArgs } from '../../types';
import { pickMarkdownRoot } from './root';
import { renderMarkdown } from './renderer';
import { stripNoise } from './utils';
export function extractMarkdown({ selector }: ContentArgs) {
const root = stripNoise(pickMarkdownRoot(selector));
return renderMarkdown(root);
}
+217
View File
@@ -0,0 +1,217 @@
import { normalizeCodeBlock } from './code';
import {
absoluteUrl,
BLOCK_TAGS,
collapseBlankLines,
escapeMarkdown,
escapeTableCell,
isNoiseElement,
normalizeInline,
normalizeText,
} from './utils';
function inlineText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeMarkdown(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "br") return "\n";
if (tag === "img") {
const img = el as HTMLImageElement;
const src = absoluteUrl(img.getAttribute("src"), img.src);
if (!src) return "";
const alt = normalizeText(img.getAttribute("alt") || "");
return alt ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`;
}
if (tag === "a") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
const href = absoluteUrl(el.getAttribute("href"), (el as HTMLAnchorElement).href);
if (!href) return text;
return `[${text || href}](${href})`;
}
if (tag === "code") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
}
if (tag === "strong" || tag === "b") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `**${text}**` : "";
}
if (tag === "em" || tag === "i") {
const text = normalizeInline(Array.from(el.childNodes).map(inlineText).join(""));
return text ? `*${text}*` : "";
}
const chunks: string[] = [];
for (const child of el.childNodes) {
const rendered = inlineText(child);
if (!rendered) continue;
chunks.push(rendered);
if (child.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has((child as Element).tagName.toLowerCase())) {
chunks.push("\n");
}
}
return chunks.join("");
}
function textBlock(node: Node): string {
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
}
function preserveNodeText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "br") return "\n";
const parts: string[] = [];
for (const child of el.childNodes) {
const rendered = preserveNodeText(child);
if (!rendered) continue;
parts.push(rendered);
}
if (["div", "p", "li"].includes(tag)) {
return `${parts.join("")}\n`;
}
return parts.join("");
}
function tableToMarkdown(table: Element) {
const rows = Array.from(table.querySelectorAll("tr"))
.map(row => Array.from(row.children)
.filter(cell => cell.tagName === "TD" || cell.tagName === "TH")
.map(cell => escapeTableCell(textBlock(cell)))
)
.filter(cells => cells.length > 0);
if (!rows.length) return "";
const widths = rows.reduce((max, row) => Math.max(max, row.length), 0);
const normalizedRows = rows.map(row => {
const next = row.slice();
while (next.length < widths) next.push("");
return next;
});
let headers = normalizedRows[0];
let bodyRows = normalizedRows.slice(1);
const firstRowIsBlank = headers.every(cell => !cell.trim());
if (firstRowIsBlank && normalizedRows.length > 1) {
headers = normalizedRows[1];
bodyRows = normalizedRows.slice(2);
}
const firstRow = table.querySelector("tr");
const thead = table.querySelector("thead");
const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH");
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
headers = new Array(widths).fill("");
bodyRows = normalizedRows;
}
const separator = new Array(widths).fill("---");
const lines = [
`| ${headers.join(" | ")} |`,
`| ${separator.join(" | ")} |`,
];
for (const row of bodyRows) {
lines.push(`| ${row.join(" | ")} |`);
}
return lines.join("\n");
}
function listToMarkdown(list: Element, depth = 0): string {
const ordered = list.tagName.toLowerCase() === "ol";
const items: string[] = [];
const children = Array.from(list.children).filter(child => child.tagName === "LI");
children.forEach((item, index) => {
const marker = ordered ? `${index + 1}. ` : "- ";
const indent = " ".repeat(depth);
const nested: string[] = [];
const content: string[] = [];
for (const child of item.childNodes) {
const childEl = child as Element;
if (child.nodeType === Node.ELEMENT_NODE && (childEl.tagName === "UL" || childEl.tagName === "OL")) {
nested.push(listToMarkdown(childEl, depth + 1));
} else {
content.push(inlineText(child));
}
}
const line = collapseBlankLines(normalizeInline(content.join("")));
if (line) {
const lineParts = line.split("\n");
items.push(`${indent}${marker}${lineParts[0]}`);
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`));
}
nested.filter(Boolean).forEach(block => items.push(block));
});
return items.join("\n");
}
function blockToMarkdown(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeText(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "table") return tableToMarkdown(el);
if (tag === "ul" || tag === "ol") return listToMarkdown(el);
if (el.matches(".cm-editor[data-is-code-block-view='true']")) {
const lines = Array.from(el.querySelectorAll(".cm-line")).map(line => {
const text = preserveNodeText(line);
return text === "\n" ? "" : text.replace(/\n$/, "");
});
const code = normalizeCodeBlock(lines.join("\n"));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "pre") {
const code = normalizeCodeBlock(preserveNodeText(el));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "blockquote") {
const content = collapseBlankLines(Array.from(el.childNodes).map(blockToMarkdown).join("\n\n"));
return content
.split("\n")
.map(line => line ? `> ${line}` : ">")
.join("\n");
}
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag.slice(1));
const text = textBlock(el);
return text ? `${"#".repeat(level)} ${text}` : "";
}
if (tag === "p" || tag === "figcaption") {
return textBlock(el);
}
if (tag === "hr") {
return "---";
}
if (tag === "img") {
return inlineText(el);
}
const childBlocks = Array.from(el.childNodes)
.map(child => blockToMarkdown(child))
.filter(Boolean);
if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n"));
return textBlock(node);
}
export function renderMarkdown(root: Element): string {
return collapseBlankLines(blockToMarkdown(root));
}
+47
View File
@@ -0,0 +1,47 @@
import { normalizeText } from './utils';
function candidateScore(node: Element) {
const text = normalizeText((node as HTMLElement).innerText || "");
if (!text) return -Infinity;
const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length;
const paragraphs = node.querySelectorAll("p").length;
const listItems = node.querySelectorAll("li").length;
const tables = node.querySelectorAll("table").length;
const codeBlocks = node.querySelectorAll("pre, code").length;
const images = node.querySelectorAll("img, figure").length;
const mainLike = node.matches("main, article, [role='main']") ? 1 : 0;
const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0;
const buttons = node.querySelectorAll("button, input, textarea, select").length;
const forms = node.querySelectorAll("form").length;
const svgs = node.querySelectorAll("svg, canvas").length;
return text.length
+ (mainLike * 4000)
+ (proseBlocks * 5000)
+ (headings * 250)
+ (paragraphs * 60)
+ (listItems * 35)
+ (tables * 80)
+ (codeBlocks * 60)
+ (images * 25)
- (buttons * 120)
- (forms * 200)
- (svgs * 40);
}
export function pickMarkdownRoot(selector?: string) {
if (selector) {
const matched = document.querySelector(selector);
if (!matched) throw new Error(`No element: ${selector}`);
return matched;
}
const candidates = Array.from(document.querySelectorAll(
"main, article, [role='main'], section, .markdown, .prose, [data-message-author-role]"
))
.filter(node => normalizeText((node as HTMLElement).innerText || "").length > 0);
if (!candidates.length) return document.body;
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
return candidates[0];
}
+85
View File
@@ -0,0 +1,85 @@
export const BLOCK_TAGS = new Set([
"article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption",
"figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr",
"li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot",
"th", "thead", "tr", "ul"
]);
export const NOISE_SELECTOR = [
"script",
"style",
"noscript",
"template",
"svg",
"canvas",
"iframe",
"dialog",
"button",
"input",
"textarea",
"select",
"option",
"form",
"[hidden]",
"[aria-hidden='true']",
".sr-only",
"[class*='sr-only']",
"[class*='file-tile']",
"form[data-type='unified-composer']",
".composer-btn",
"[data-composer-surface='true']",
"#thread-bottom-container",
"[data-testid*='action-button']",
].join(", ");
export function normalizeText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
export function normalizeInline(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
export function collapseBlankLines(value: string) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function escapeMarkdown(text: string) {
return text.replace(/([\\`[\]])/g, "\\$1");
}
export function escapeTableCell(text: string) {
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
export function absoluteUrl(attr: string | null | undefined, fallback?: string) {
return attr || fallback || "";
}
export function isNoiseElement(node: Node | null): boolean {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true;
if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true;
if (el.hasAttribute("hidden")) return true;
if ((el.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
if (el.matches(".sr-only, [class*='sr-only']")) return true;
if (el.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
if (el.matches("[data-testid*='action-button']")) return true;
return false;
}
export function stripNoise(root: Element): Element {
const clone = root.cloneNode(true) as Element;
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
return clone;
}
+1
View File
@@ -22,6 +22,7 @@ export function tabInfo(t: Tab) {
windowId: t.windowId, windowId: t.windowId,
active: t.active, active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted), muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
index: t.index,
groupId: t.groupId >= 0 ? t.groupId : null, groupId: t.groupId >= 0 ? t.groupId : null,
title: t.title, title: t.title,
url: t.url || t.pendingUrl || "", url: t.url || t.pendingUrl || "",
+12
View File
@@ -74,6 +74,18 @@ version-check:
ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \ ext=$(grep -m1 '"version"' extension/manifest.json | cut -d'"' -f4); \
if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi if [ "$py" = "$ext" ]; then echo "ok: $py"; else echo "MISMATCH pyproject=$py manifest=$ext"; exit 1; fi
# Build into /tmp/dist-browser-cli and publish using credentials from .env
publish:
#!/usr/bin/env bash
set -euo pipefail
set -a
[ ! -f .env ] || source .env
set +a
rm -rf /tmp/dist-browser-cli
mkdir -p /tmp/dist-browser-cli
uv build --out-dir /tmp/dist-browser-cli
uv publish /tmp/dist-browser-cli/*
# ── Demos ────────────────────────────────────────────────────────────── # ── Demos ──────────────────────────────────────────────────────────────
# Run the Python SDK demo # Run the Python SDK demo
+1
View File
@@ -9,6 +9,7 @@
"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": "npm run build:extension && python scripts/package_extension.py",
"package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore", "package:extension:webstore": "npm run build:extension && python scripts/package_extension.py --webstore",
"package:extension:webstore:verified": "scripts/package_verified_crx.sh",
"package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox" "package:extension:firefox": "npm run build:extension && python scripts/package_extension.py --firefox"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.2" version = "0.15.6"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/package_verified_crx.sh [--key FILE.gpg] [--browser COMMAND] [--out FILE.crx]
Builds the Chrome Web Store package and creates a CRX signed with the dedicated
verified-upload RSA key. The RSA private key is expected to be GPG-encrypted.
Environment alternatives:
VERIFIED_CRX_KEY_GPG Path to encrypted RSA private key
CHROME_FOR_PACKING Browser command with --pack-extension support
EOF
}
key_gpg="${VERIFIED_CRX_KEY_GPG:-secrets/verified-crx/chrome-webstore-verified-crx-private-key.pem.gpg}"
browser_cmd="${CHROME_FOR_PACKING:-}"
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--key)
key_gpg="${2:-}"
shift 2
;;
--browser)
browser_cmd="${2:-}"
shift 2
;;
--out)
out="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! -f "$key_gpg" ]]; then
echo "Encrypted verified CRX key not found: $key_gpg" >&2
echo "Create it with: scripts/setup_verified_crx_key.sh --recipient '<your GPG key>'" >&2
exit 1
fi
if [[ -z "$browser_cmd" ]]; then
for candidate in google-chrome chrome chromium chromium-browser brave-browser brave; do
if command -v "$candidate" >/dev/null 2>&1; then
browser_cmd="$candidate"
break
fi
done
fi
if [[ -z "$browser_cmd" ]]; then
echo "No Chromium-based browser with --pack-extension found. Pass --browser or set CHROME_FOR_PACKING." >&2
exit 1
fi
version="$(python - <<'PY'
import json
from pathlib import Path
print(json.loads(Path('extension/manifest.json').read_text())['version'])
PY
)"
out="${out:-dist/browser-cli-extension-webstore-verified-v${version}.crx}"
npm run build:extension
python scripts/package_extension.py --webstore --out "dist/browser-cli-extension-webstore-v${version}.zip" >/dev/null
staging="$PWD/dist/extension-package-webstore"
if [[ ! -d "$staging" ]]; then
echo "Missing webstore staging directory: $staging" >&2
exit 1
fi
tmp_dir="$(mktemp -d)"
private_key="$tmp_dir/verified-crx-private-key.pem"
trap 'rm -rf "$tmp_dir"' EXIT
gpg --decrypt --output "$private_key" "$key_gpg"
chmod 600 "$private_key"
rm -f "$staging.crx"
"$browser_cmd" \
--pack-extension="$staging" \
--pack-extension-key="$private_key" \
--no-message-box \
--disable-gpu \
--no-sandbox >/dev/null
mkdir -p "$(dirname "$out")"
mv "$staging.crx" "$out"
echo "$out"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/setup_verified_crx_key.sh [--recipient GPG_RECIPIENT] [--out-dir DIR]
Generates a dedicated RSA private key for Chrome Web Store verified CRX uploads,
encrypts it to your GPG key, and writes the public key material for the Chrome
Developer Dashboard.
Chrome Web Store verified uploads require an RSA CRX signing key. A GPG/OpenPGP
key cannot be used directly for CRX signing, but it can protect the RSA private
key at rest.
EOF
}
recipient=""
out_dir="secrets/verified-crx"
while [[ $# -gt 0 ]]; do
case "$1" in
--recipient)
recipient="${2:-}"
shift 2
;;
--out-dir)
out_dir="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$recipient" ]]; then
recipient="$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1 == "uid" { print $10; exit }')"
fi
if [[ -z "$recipient" ]]; then
echo "No GPG recipient found. Pass --recipient '<key id or email>'." >&2
exit 1
fi
mkdir -p "$out_dir"
chmod 700 "$out_dir"
private_key="$(mktemp)"
public_pem="$out_dir/chrome-webstore-verified-crx-public-key.pem"
public_der_b64="$out_dir/chrome-webstore-verified-crx-public-key.der.base64.txt"
encrypted_private="$out_dir/chrome-webstore-verified-crx-private-key.pem.gpg"
trap 'rm -f "$private_key"' EXIT
if [[ -e "$encrypted_private" ]]; then
echo "Refusing to overwrite existing encrypted private key: $encrypted_private" >&2
exit 1
fi
openssl genrsa -out "$private_key" 2048 >/dev/null 2>&1
chmod 600 "$private_key"
openssl rsa -in "$private_key" -pubout -out "$public_pem" >/dev/null 2>&1
openssl rsa -in "$private_key" -pubout -outform DER 2>/dev/null | base64 -w0 > "$public_der_b64"
printf '\n' >> "$public_der_b64"
gpg --encrypt --recipient "$recipient" --output "$encrypted_private" "$private_key"
chmod 600 "$encrypted_private"
cat <<EOF
Created verified CRX upload key material:
encrypted private key: $encrypted_private
public key PEM: $public_pem
public key DER/base64: $public_der_b64
Use the public key in the Chrome Developer Dashboard -> Package -> Verified uploads.
Keep the encrypted private key. Do not commit or upload the decrypted PEM.
EOF
+90 -8
View File
@@ -18,6 +18,7 @@ TAB_DATA = {
"id": 10, "id": 10,
"windowId": 1, "windowId": 1,
"active": True, "active": True,
"index": 3,
"title": "Example", "title": "Example",
"url": "https://example.com", "url": "https://example.com",
"groupId": None, "groupId": None,
@@ -73,6 +74,15 @@ class TestBrowserCLIInit:
assert b.remote == "browser-host.example:443" assert b.remote == "browser-host.example:443"
assert b.key == "agent" assert b.key == "agent"
def test_tab_factory_preserves_index(self):
tab = BrowserCLI().tab_from(TAB_DATA)
assert tab.index == 3
def test_tab_factory_defaults_missing_index_to_zero(self):
data = {key: value for key, value in TAB_DATA.items() if key != "index"}
tab = BrowserCLI().tab_from(data)
assert tab.index == 0
def test_namespaces_present_and_bound(self): def test_namespaces_present_and_bound(self):
b = BrowserCLI() b = BrowserCLI()
for name in ("nav", "tabs", "groups", "windows", "dom", "extract", for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
@@ -354,6 +364,27 @@ class TestTabs:
profile=None, remote=None, key=None, profile=None, remote=None, key=None,
) )
def test_tabs_close_by_ids_chunks_large_batches(self, b, mock_send):
mock_send.side_effect = [{"closed": 50}, {"closed": 50}, {"closed": 20}]
assert b.tabs.close(tab_ids=range(120), gentle_mode="normal") == 120
assert mock_send.call_args_list == [
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(0, 50)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(50, 100)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(100, 120)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
]
def test_tabs_move(self, b, mock_send): def test_tabs_move(self, b, mock_send):
b.tabs.move(10, forward=True) b.tabs.move(10, forward=True)
mock_send.assert_called_once_with( mock_send.assert_called_once_with(
@@ -439,7 +470,7 @@ class TestTabs:
tabs = b.tabs.list() tabs = b.tabs.list()
tabs[0].close() tabs[0].close()
assert [tab.browser for tab in tabs] == ["host:work"] assert [tab.browser for tab in tabs] == ["work"]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", key=None), call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None), call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
@@ -460,6 +491,28 @@ class TestTabs:
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"), call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
] ]
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
b = BrowserCLI(browser="browser-host.example", key="agent")
with patch(
"browser_cli.remote_targets_for_alias",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
],
):
mock_send.side_effect = [[TAB_DATA], [{**TAB_DATA, "id": 11}], None]
tabs = b.tabs.list()
tabs[1].close()
assert [tab.browser for tab in tabs] == ["main", "work"]
assert [tab.browser_name for tab in tabs] == ["Chrome", "Firefox"]
assert [tab.browser_group for tab in tabs] == [None, None]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
]
def test_tabs_active_returns_active_tab(self, b, mock_send): def test_tabs_active_returns_active_tab(self, b, mock_send):
mock_send.side_effect = [[TAB_DATA], TAB_DATA] mock_send.side_effect = [[TAB_DATA], TAB_DATA]
@@ -506,12 +559,37 @@ class TestTabs:
mock_send.side_effect = [3, 4] mock_send.side_effect = [3, 4]
result = b.tabs.count("github") result = b.tabs.count("github")
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4}) assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 3, "work": 4},
browser_groups={"uuid-1": "local", "work": "local"},
)
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("tabs.count", {"pattern": "github"}, profile="default"), call("tabs.count", {"pattern": "github"}, profile="default"),
call("tabs.count", {"pattern": "github"}, profile="work"), call("tabs.count", {"pattern": "github"}, profile="work"),
] ]
def test_tabs_count_multi_browser_keeps_remote_display_groups(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
],
):
mock_send.side_effect = [1, 2]
result = b.tabs.count()
assert result == BrowserCounts(
total=3,
by_browser={"browser-host.example:main": 1, "browser-host.example:work": 2},
browser_groups={"browser-host.example:main": "browser-host.example", "browser-host.example:work": "browser-host.example"},
)
assert mock_send.call_args_list == [
call("tabs.count", {"pattern": None}, profile="main", remote="browser-host.example:8765", key=None),
call("tabs.count", {"pattern": None}, profile="work", remote="browser-host.example:8765", key=None),
]
def test_tabs_query(self, b, mock_send): def test_tabs_query(self, b, mock_send):
mock_send.return_value = [TAB_DATA] mock_send.return_value = [TAB_DATA]
result = b.tabs.query("example") result = b.tabs.query("example")
@@ -628,7 +706,7 @@ class TestGroups:
groups = b.groups.list() groups = b.groups.list()
groups[0].close() groups[0].close()
assert [group.browser for group in groups] == ["host:work"] assert [group.browser for group in groups] == ["work"]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", key=None), call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None), call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
@@ -660,7 +738,11 @@ class TestGroups:
mock_send.side_effect = [2, 5] mock_send.side_effect = [2, 5]
result = b.groups.count() result = b.groups.count()
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 2, "work": 5}) assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 2, "work": 5},
browser_groups={"uuid-1": "local", "work": "local"},
)
def test_group_query(self, b, mock_send): def test_group_query(self, b, mock_send):
mock_send.return_value = [GROUP_DATA] mock_send.return_value = [GROUP_DATA]
@@ -719,8 +801,8 @@ class TestWindows:
result = b.windows.list() result = b.windows.list()
assert result == [ assert result == [
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"}, {"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"},
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"}, {"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"},
] ]
def test_windows_open_without_url(self, b, mock_send): def test_windows_open_without_url(self, b, mock_send):
@@ -854,8 +936,8 @@ class TestSession:
result = b.session.list() result = b.session.list()
assert result == [ assert result == [
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"}, {"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"},
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"}, {"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"},
] ]
assert mock_send.call_args_list == [ assert mock_send.call_args_list == [
call("session.list", {}, profile="default"), call("session.list", {}, profile="default"),
+216 -6
View File
@@ -4,7 +4,7 @@ import os
import sys import sys
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch from unittest.mock import call, patch
from browser_cli.cli import main, _project_version from browser_cli.cli import main, _project_version
from browser_cli.client import BrowserTarget from browser_cli.client import BrowserTarget
@@ -141,9 +141,9 @@ def test_install_writes_firefox_allowed_extensions(tmp_path):
] ]
assert "about:debugging#/runtime/this-firefox" in result.output assert "about:debugging#/runtime/this-firefox" in result.output
assert "npm run package:extension:firefox" in result.output assert "npm run package:extension:firefox" in result.output
assert "dist/extension-package-firefo" in result.output output_unwrapped = result.output.replace("\n", "")
assert "x/manifest.json" in result.output assert "dist/extension-package-firefox/manifest.json" in output_unwrapped
assert "Do not select extension/manifest.json" in result.output assert "Do not select extension/manifest.json" in output_unwrapped
assert "Firefox extension ID" in result.output assert "Firefox extension ID" in result.output
def test_install_windows_registers_native_host(tmp_path): def test_install_windows_registers_native_host(tmp_path):
@@ -217,7 +217,13 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8") registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}', encoding="utf-8")
remote_target = BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765") remote_target = BrowserTarget(
"work",
"browser-host.example:work",
"",
remote="browser-host.example:8765",
display_group="browser-host.example",
)
def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False): def fake_send_command(command, args=None, profile=None, remote=None, key=None, suppress_pq_warning=False):
assert command == "clients.list" assert command == "clients.list"
@@ -239,7 +245,11 @@ def test_clients_without_remote_shows_saved_remotes_without_pq_warning(tmp_path)
assert result.exit_code == 0 assert result.exit_code == 0
active_targets.assert_called_once_with(suppress_pq_warning=True) active_targets.assert_called_once_with(suppress_pq_warning=True)
assert "local" in result.output
assert "Chrome" in result.output assert "Chrome" in result.output
assert "browser-host.example" in result.output
assert "browser-host.example:work" not in result.output
assert "work" in result.output
assert "Remote Chrome" in result.output assert "Remote Chrome" in result.output
assert "post-quantum" not in result.output assert "post-quantum" not in result.output
@@ -362,9 +372,33 @@ def test_tabs_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "550e8400-e29b-41d4-a716-446655440000" in result.output assert "550e8400-e29b-41d4-a716-446655440000" in result.output
assert "work" in result.output assert "work" in result.output
def test_tabs_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "tabs.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_tabs_list_with_remote_uses_only_remote_targets(): def test_tabs_list_with_remote_uses_only_remote_targets():
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
@@ -379,7 +413,8 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"]) result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "remote-host:work" in result.output assert "work" in result.output
assert "remote-host:work" not in result.output
assert "Remote" in result.output assert "Remote" in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None) send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None)
@@ -400,6 +435,81 @@ def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
assert "Browser" not in result.output assert "Browser" not in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None) send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None)
def test_tabs_tree_with_browser_host_alias_fans_out_to_remote_targets():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert remote == "browser-host.example:8765"
if command == "tabs.list":
return [{
"id": 1 if profile == "main" else 2,
"windowId": 1,
"active": True,
"index": 0,
"title": f"{profile} tab",
"url": "https://example.com",
}]
if command == "group.list":
return []
raise AssertionError(command)
with patch("browser_cli.remote_targets_for_alias", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
) as send_command:
result = CliRunner().invoke(main, ["--browser", "browser-host.example", "tabs", "tree"])
assert result.exit_code == 0
assert "main" in result.output
assert "work" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main tab" in result.output
assert "work tab" in result.output
assert send_command.call_args_list == [
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key=None),
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key=None),
call("group.list", {}, profile="main", remote="browser-host.example:8765", key=None),
call("group.list", {}, profile="work", remote="browser-host.example:8765", key=None),
]
def test_tabs_tree_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert remote == "browser-host.example:8765"
if command == "tabs.list":
return [{
"id": 1 if profile == "main" else 2,
"windowId": 1,
"active": True,
"index": 0,
"title": f"{profile} tab",
"url": "https://example.com",
}]
if command == "group.list":
return []
raise AssertionError(command)
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "main" in result.output
assert "work" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main tab" in result.output
assert "work tab" in result.output
def test_tabs_count_multi_browser_shows_total(): def test_tabs_count_multi_browser_shows_total():
counts = {"default": 3, "work": 4} counts = {"default": 3, "work": 4}
@@ -419,9 +529,36 @@ def test_tabs_count_multi_browser_shows_total():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "Total" in result.output assert "Total" in result.output
assert "7" in result.output assert "7" in result.output
def test_tabs_count_unscoped_groups_remote_targets_by_host():
counts = {"main": 1, "work": 2}
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "tabs.count"
assert remote == "browser-host.example:8765"
return counts[profile]
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
],
), patch("browser_cli.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["tabs", "count"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
assert "Total" in result.output
assert "3" in result.output
def test_group_count_multi_browser_shows_total(): def test_group_count_multi_browser_shows_total():
counts = {"default": 1, "work": 2} counts = {"default": 1, "work": 2}
@@ -443,6 +580,29 @@ def test_group_count_multi_browser_shows_total():
assert "Total" in result.output assert "Total" in result.output
assert "3" in result.output assert "3" in result.output
def test_groups_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "group.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "title": profile, "color": "blue", "collapsed": False, "tabCount": 2}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["groups", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_group_list_leaves_unnamed_group_cell_empty(): def test_group_list_leaves_unnamed_group_cell_empty():
with patch( with patch(
"browser_cli.send_command", "browser_cli.send_command",
@@ -491,10 +651,34 @@ def test_windows_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "Focused" not in result.output assert "Focused" not in result.output
assert "uuid-1" in result.output assert "uuid-1" in result.output
assert "work" in result.output assert "work" in result.output
def test_windows_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "windows.list"
assert remote == "browser-host.example:8765"
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["windows", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
def test_session_list_multi_browser_shows_browser_column(): def test_session_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None): def fake_send_command(command, args=None, profile=None):
assert command == "session.list" assert command == "session.list"
@@ -511,11 +695,37 @@ def test_session_list_multi_browser_shows_browser_column():
assert result.exit_code == 0 assert result.exit_code == 0
assert "Browser" in result.output assert "Browser" in result.output
assert "local" in result.output
assert "uuid-1" in result.output assert "uuid-1" in result.output
assert "work" in result.output assert "work" in result.output
assert "default-session" in result.output assert "default-session" in result.output
assert "work-session" in result.output assert "work-session" in result.output
def test_session_list_unscoped_groups_remote_targets_by_host():
targets = [
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "session.list"
assert remote == "browser-host.example:8765"
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
with patch("browser_cli.active_browser_targets", return_value=targets), patch(
"browser_cli.send_command", side_effect=fake_send_command
):
result = CliRunner().invoke(main, ["session", "list"])
assert result.exit_code == 0
assert "browser-host.example" in result.output
assert "browser-host.example:main" not in result.output
assert "browser-host.example:work" not in result.output
assert "main" in result.output
assert "work" in result.output
assert "main-session" in result.output
assert "work-session" in result.output
def test_session_list_with_explicit_browser_does_not_show_browser_column(): def test_session_list_with_explicit_browser_does_not_show_browser_column():
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
+3 -1
View File
@@ -342,7 +342,7 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
def fake_send_command(command, args=None, profile=None, remote=None, key=None): def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "browser-cli.targets" assert command == "browser-cli.targets"
assert remote == endpoint assert remote == endpoint
return [{"profile": "work", "displayName": "work"}] return [{"profile": "work", "displayName": "work", "browserName": "Firefox"}]
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command) monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
@@ -352,6 +352,8 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
assert targets[0].profile == "work" assert targets[0].profile == "work"
assert targets[0].display_name == "browser-host.example:work" assert targets[0].display_name == "browser-host.example:work"
assert targets[0].remote == endpoint assert targets[0].remote == endpoint
assert targets[0].browser_name == "Firefox"
assert targets[0].display_group == "browser-host.example"
def test_looks_like_domain(): def test_looks_like_domain():
assert _looks_like_domain("browsercli.yiprawr.dev") is True assert _looks_like_domain("browsercli.yiprawr.dev") is True
+84 -1
View File
@@ -7,6 +7,7 @@ from click.testing import CliRunner
import pytest import pytest
from browser_cli import BrowserCLI from browser_cli import BrowserCLI
from browser_cli.client import BrowserTarget
from browser_cli.cli import main from browser_cli.cli import main
from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category from browser_cli.command_security import CommandPolicy, assert_command_allowed, command_category
@@ -47,12 +48,94 @@ def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
("navigate.to", {"tabId": 7, "url": "https://example.com"}), ("navigate.to", {"tabId": 7, "url": "https://example.com"}),
] ]
def _tree_sender(tabs, groups):
def sender(command, args=None, **kwargs):
if command == "tabs.list":
return tabs
if command == "group.list":
return groups
return []
return sender
def test_tabs_tree_command_available(): def test_tabs_tree_command_available():
with patch("browser_cli.send_command", return_value=[]): with patch("browser_cli.send_command", side_effect=_tree_sender([], [])):
result = CliRunner().invoke(main, ["tabs", "tree"]) result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Tabs" in result.output assert "Tabs" in result.output
def test_tabs_tree_handles_tabs_without_index_from_older_extension():
tabs = [{
"id": 7,
"windowId": 1,
"active": True,
"muted": False,
"title": "Example",
"url": "https://example.com",
"groupId": None,
}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert "Example" in result.output
def test_tabs_tree_preserves_window_tab_order_and_truncates_long_lines():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "[Gold] Grouped", "url": "https://example.com/grouped", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/" + "x" * 200, "groupId": None},
]
groups = [{"id": 20, "title": "Group Name", "color": "blue", "collapsed": False, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
output = result.output
assert output.index("Before") < output.index("Group Name") < output.index("[Gold] Grouped") < output.index("After")
assert "https://example.com/before" not in output
assert "https://example.com/grouped" not in output
assert "https://example.com/" + "x" * 200 not in output
def test_tabs_tree_adds_each_browser_node_only_once():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "One", "url": "https://example.com/one", "groupId": None, "browser": "work"},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Two", "url": "https://example.com/two", "groupId": None, "browser": "work"},
]
targets = [
BrowserTarget("work", "work", "/tmp/work.sock"),
BrowserTarget("personal", "personal", "/tmp/personal.sock"),
]
with patch("browser_cli.active_browser_targets", return_value=targets), \
patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert result.output.count("work") == 1
assert result.output.count("personal") == 1
assert "One" in result.output
assert "Two" in result.output
def test_tabs_tree_shows_tabs_inside_collapsed_browser_groups():
tabs = [
{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Before", "url": "https://example.com/before", "groupId": None},
{"id": 2, "windowId": 1, "index": 1, "active": False, "title": "Hidden", "url": "https://example.com/hidden", "groupId": 20},
{"id": 3, "windowId": 1, "index": 2, "active": False, "title": "After", "url": "https://example.com/after", "groupId": None},
]
groups = [{"id": 20, "title": "Collapsed Group", "color": "orange", "collapsed": True, "tabCount": 1, "windowId": 1}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, groups)):
result = CliRunner().invoke(main, ["tabs", "tree"])
assert result.exit_code == 0
assert "Collapsed Group" in result.output
assert "1 tab" in result.output
assert "collapsed" in result.output
assert "Hidden" in result.output
def test_tabs_tree_can_show_shortened_urls_on_request():
tabs = [{"id": 1, "windowId": 1, "index": 0, "active": False, "title": "Long URL", "url": "https://example.com/" + "x" * 200, "groupId": None}]
with patch("browser_cli.send_command", side_effect=_tree_sender(tabs, [])):
result = CliRunner().invoke(main, ["tabs", "tree", "--urls"])
assert result.exit_code == 0
assert "https://example.com/" in result.output
assert "https://example.com/" + "x" * 200 not in result.output
assert "" in result.output
def test_doctor_command_reports_connection_failure_cleanly(): def test_doctor_command_reports_connection_failure_cleanly():
with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \ with patch("browser_cli.commands.doctor.active_browser_targets", return_value=[]), \
patch("browser_cli.send_command", side_effect=RuntimeError("no browser")): patch("browser_cli.send_command", side_effect=RuntimeError("no browser")):
+91
View File
@@ -0,0 +1,91 @@
from os import terminal_size
from rich.console import Console
from rich.tree import Tree
from browser_cli.models import Tab
from browser_cli.commands import rendering
from browser_cli.commands.rendering import common
def test_shorten_uses_ellipsis():
assert rendering.shorten("abcdef", 4) == "abc…"
assert rendering.shorten("abc", 4) == "abc"
def test_terminal_width_prefers_shell_width_when_rich_is_redirected(monkeypatch):
monkeypatch.setattr(common.shutil, "get_terminal_size", lambda fallback: terminal_size((140, 20)))
assert rendering.terminal_width(Console(width=80)) == 140
def test_browser_label_style_distinguishes_browser_families():
assert rendering.browser_label_style("Firefox") == "orange1"
assert rendering.browser_label_style("Chrome") == "cyan"
assert rendering.browser_label_style(None) == "bold cyan"
def test_scoped_browser_label_strips_repeated_remote_prefix():
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=True) == "work"
assert rendering.scoped_browser_label("work", "browser-host.example", grouped=True) == "work"
assert rendering.scoped_browser_label("browser-host.example:work", "browser-host.example", grouped=False) == "browser-host.example:work"
def test_tab_tree_label_is_reusable_no_wrap_text():
tab = type("Tab", (), {"id": 1, "title": "abcdef", "active": True, "url": "https://example.com"})()
label = rendering.tab_tree_label(tab, title_limit=4, show_urls=True, url_limit=12)
assert label.no_wrap is True
assert label.overflow == "ellipsis"
assert "abc…" in label.plain
assert "https://exa…" in label.plain
def test_print_tree_uses_detected_width(monkeypatch):
widths = []
class CapturingConsole(Console):
def __init__(self, *args, **kwargs):
widths.append(kwargs.get("width"))
super().__init__(*args, **kwargs)
monkeypatch.setattr(common, "Console", CapturingConsole)
monkeypatch.setattr(common, "terminal_width", lambda console=None: 132)
rendering.print_tree(Tree("Root"))
assert widths == [132]
def test_build_tabs_tree_groups_by_browser_window_and_group():
tabs = [
Tab(id=1, window_id=5, active=False, muted=False, title="Before", url="https://example.com/before", group_id=None, index=0, browser="work"),
Tab(id=2, window_id=5, active=False, muted=False, title="Inside", url="https://example.com/inside", group_id=9, index=1, browser="work"),
]
groups = [{"id": 9, "windowId": 5, "browser": "work", "title": "Research", "color": "blue", "tabCount": 1, "collapsed": True}]
tree = rendering.build_tabs_tree(tabs, groups, console=Console(width=120), show_urls=True)
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
assert "work" in text
assert "Window 5" in text
assert "Research" in text
assert "collapsed" in text
assert "Inside" in text
def test_build_tabs_tree_groups_remote_browsers_by_scope():
tabs = [
Tab(id=1, window_id=5, active=False, muted=False, title="Remote A", url="https://example.com/a", index=0, browser="main", browser_group="browser-host.example"),
Tab(id=2, window_id=6, active=False, muted=False, title="Remote B", url="https://example.com/b", index=0, browser="work", browser_group="browser-host.example"),
Tab(id=3, window_id=7, active=False, muted=False, title="Local", url="https://example.com/local", index=0, browser="local"),
]
tree = rendering.build_tabs_tree(tabs, [], console=Console(width=120))
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
assert "browser-host.example" in text
assert "main" in text
assert "work" in text
assert "browser-host.example:main" not in text
assert "browser-host.example:work" not in text
assert "Local" in text
def test_build_windows_tree_keeps_multi_browser_windows_separate():
tabs = [
Tab(id=1, window_id=5, active=False, muted=False, title="Work Tab", url="https://example.com/work", index=0, browser="work"),
Tab(id=2, window_id=5, active=False, muted=False, title="Personal Tab", url="https://example.com/personal", index=0, browser="personal"),
]
windows = [
{"id": 5, "alias": "main", "browser": "work", "tabCount": 1, "state": "normal"},
{"id": 5, "alias": "main", "browser": "personal", "tabCount": 1, "state": "normal"},
]
tree = rendering.build_windows_tree(windows, tabs, console=Console(width=120))
text = "\n".join(str(line) for line in tree.__rich_console__(Console(width=120), Console(width=120).options))
assert "work: Window 5" in text
assert "personal: Window 5" in text
assert "Work Tab" in text
assert "Personal Tab" in text
Generated
+1 -1
View File
@@ -465,7 +465,7 @@ wheels = [
[[package]] [[package]]
name = "real-browser-cli" name = "real-browser-cli"
version = "0.15.2" version = "0.15.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },