Compare commits

..

32 Commits

Author SHA1 Message Date
daniel156161 c1a5ef9dd7 feat: token-auth removal, security hardening, Stripe-style compat layer (v0.9.2)
Testing / test (push) Successful in 41s
Package Extension / package-extension (push) Successful in 35s
Build & Publish Package / publish (push) Successful in 46s
- Remove token auth entirely; only Ed25519 pubkey auth or --no-auth
- Add 32 MB message-size cap in serve and client (DoS protection)
- Set Unix socket to 0o600 after bind in native_host (multi-user hardening)
- Enforce browser-cli/VERSION user-agent on all TCP connections
- Add PROTOCOL_MIN_CLIENT check (>= 0.9.0) server- and client-side
- Include server_version + min_client_version in challenge frame
- Add browser_cli/version_manager.py: parse_version, get_installed_version
- Add browser_cli/compat.py: Stripe-style versioning layer with adapt_request
  / adapt_response hooks; baseline 0.9.2, no shims needed yet
- Fix BrowserCLI key handling: no Path() wrap for agent specs
- Fix _multi_browser_targets() to forward key to remote_browser_targets()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:59:46 +02:00
daniel156161 b98c4ae116 fix: validate key spec before saving/loading from remotes.json
Testing / test (push) Successful in 29s
A previous bug (fixed in fcd2e8b) caused str(AgentKey(...)) to be saved
as the key spec instead of the plain string "agent". This made
_load_private_key() return None, sending messages unsigned.

- _is_valid_key_spec() guards save_remote_key() against persisting
  serialized objects or other non-spec values
- key_for_remote() rejects already-persisted corrupt specs so fallback
  key loading still works

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:15:09 +02:00
daniel156161 fcd2e8b87b fix: skip route-resolution for server-side auth commands; pass key_spec not loaded key
Testing / test (push) Failing after 14m2s
browser-cli.auth.keys and browser-cli.auth.trust are handled by serve.py
directly and never need a _route profile, so they no longer trigger
_auto_route_remote (which would open a second connection just to discover
available browser profiles).

Also fixes _auto_route_remote receiving an already-loaded AgentKey object
instead of the key spec string — the nested send_command call couldn't
re-load it for signing, causing auth failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:03:50 +02:00
daniel156161 b87f536ecd chore: bump version to 0.9.1
Testing / test (push) Failing after 11m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:56:39 +02:00
daniel156161 a2aa031d71 feat: auth keys shows trusted keys with names; remote auth trust/keys
- authorized_keys format extended to '<hex> [optional-name]'
- auth keys repurposed: shows server's trusted keys (Name/Public Key table)
  instead of local client keys; --remote queries the remote serve instance
- auth trust gains --name flag for labelling keys; --remote pushes the key
  to the remote server's authorized_keys
- serve.py handles browser-cli.auth.keys and browser-cli.auth.trust as
  server-side commands (authenticated, never forwarded to native host)
- serve.py reloads authorized_keys from disk on every connection so
  auth trust --remote takes effect immediately without restarting serve
- auth show unchanged: still prints your own client public key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:54:41 +02:00
daniel156161 8593916e5a fix: propagate key through remote discovery; auto-persist key per remote
- remote_browser_targets(), _auto_route_remote(), active_browser_targets()
  now accept and forward the key parameter so pubkey auth works during
  the initial browser-cli.targets discovery call
- _multi_browser_targets() in tabs/groups/windows/session commands now
  reads key from ctx.obj and passes it through
- send_command() auto-saves the key spec (e.g. "agent") to remotes.json
  on first explicit use; subsequent calls to the same remote reuse it
  without requiring --key every time
- Added save_remote_key() / key_for_remote() helpers (mirrors token helpers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:50:51 +02:00
daniel156161 4b2abbbfc5 feat: Ed25519 challenge-response auth + YubiKey/SSH agent support (v0.9.0)
Testing / test (push) Successful in 26s
Package Extension / package-extension (push) Successful in 22s
Build & Publish Package / publish (push) Successful in 27s
Security:
- serve.py: server now sends nonce challenge before accepting any command;
  clients sign nonce + SHA256(canonical_payload) with Ed25519 key
- New --authorized-keys FILE option for serve; token auth still works as fallback
- Connection limit: BoundedSemaphore(64) in serve.py
- Secure file creation with os.open(..., 0o600) for token/key files
- New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python),
  sign/verify helpers compatible with both file keys and agent-held keys (YubiKey,
  TPM, gpg-agent)

Features:
- YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK
- New `browser-cli auth` command group: keygen, trust, show, keys
- Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key;
  pass "agent" or "agent:<selector>" to use SSH agent key
- BrowserCLI Python API gains key= parameter

Bug fixes (11 issues across two review passes):
- client.py: check response is not None before json.loads
- native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows
  Listener leak on accept error
- __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None
- extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback
- extension/session.ts: removeListener before addListener prevents duplicate handlers

Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:20:39 +02:00
daniel156161 9f03e29807 chore: bump version to 0.8.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:05:51 +02:00
daniel156161 e1ff67e259 chore: bump npm devDependencies to latest
@types/chrome 0.0.326 → 0.1.40
esbuild 0.25.12 → 0.28.0
typescript 5.9.3 → 6.0.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:04:20 +02:00
daniel156161 a8421e97f5 fix: harden IPC, screenshot, paging, and tab filter error handling
- tabs.py: validate screenshot data URL prefix and catch binascii.Error
  instead of silently writing a zero-byte file or crashing with a raw traceback
- serve.py: add 30 s recv timeout on client connections to prevent unbounded
  thread accumulation; use hmac.compare_digest for constant-time token check
- native_host.py: bind Unix socket before _registry_add to eliminate the
  window where the registry points to an unbound path; cap paging loop at
  ceil(10000/PAGE_SIZE) iterations to guard against a misbehaving extension;
  remove dead no-hello fast-path queue that was registered but never consumed
- __init__.py: narrow _apply_tab_filter except to (AttributeError, TypeError)
  so broken filter functions raise instead of silently returning wrong results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:03:01 +02:00
daniel156161 753e4c4449 update version to 0.8.6 and update requirements
Testing / test (push) Successful in 23s
Package Extension / package-extension (push) Successful in 21s
Build & Publish Package / publish (push) Successful in 22s
2026-05-02 12:44:54 +02:00
daniel156161 f1734cd2c1 make remote browser listing more simpler when giving --browser ip only shows remote browsers
Testing / test (push) Successful in 22s
2026-05-02 12:42:21 +02:00
daniel156161 22f39a1a77 fix echo of versions that uv not shows uv uv 0.9.29 and look more normal like uv 0.9.29
Testing / test (push) Successful in 37s
2026-05-02 12:26:23 +02:00
daniel156161 a9071abc9a cleanup tests
Package Extension / package-extension (push) Successful in 24s
Build & Publish Package / publish (push) Successful in 31s
Testing / test (push) Successful in 26s
2026-05-02 01:48:13 +02:00
daniel156161 edafd349df use full terminal columns for completion test and add native host app as a single script wraper for native host app import 2026-05-02 01:14:28 +02:00
daniel156161 9435dcc716 target first remote browser when not giving it with a alias and update version to 0.8.4
Testing / test (push) Successful in 34s
Package Extension / package-extension (push) Successful in 1m0s
Build & Publish Package / publish (push) Successful in 37s
2026-05-01 20:14:40 +02:00
daniel156161 bd37c68e80 fix bug that getAliases got not found and update version to 0.8.3
Testing / test (push) Successful in 26s
2026-05-01 20:04:34 +02:00
daniel156161 ffa76f424a implement same functionality into BrowserCLI python package 2026-05-01 20:01:55 +02:00
daniel156161 647867d05e make it easyer to connect to a remove browser allow it with --browser ip alias too
Testing / test (push) Failing after 13m59s
2026-05-01 19:55:02 +02:00
daniel156161 f836844791 run nm ci when not already installed into repository folder
Testing / test (push) Failing after 14m20s
2026-05-01 19:44:18 +02:00
daniel156161 6f7c4fc7ea add nix shell file to build background.js
Testing / test (push) Failing after 14m25s
2026-05-01 19:39:33 +02:00
daniel156161 1b0e090466 update version to 0.8.2
Testing / test (push) Failing after 12m47s
2026-05-01 19:30:29 +02:00
daniel156161 d904f4ca63 move registry into own files 2026-05-01 19:30:09 +02:00
daniel156161 7ee664153b change background.js into split typescript files for better managemand and build background.js from typescript 2026-05-01 19:28:36 +02:00
daniel156161 fb78fd0471 impplement pageing between native host and browser extension 2026-05-01 19:07:46 +02:00
daniel156161 5ff340a6d3 allow to ask for remote host profiles and save token on first connection for later use 2026-05-01 19:07:04 +02:00
daniel156161 2f982fa714 fix remote clients command
Testing / test (push) Successful in 25s
Package Extension / package-extension (push) Successful in 9s
Build & Publish Package / publish (push) Successful in 21s
2026-04-30 13:49:32 +02:00
daniel156161 6785b9f70c feat(serve): add remote browser control over TCP with token auth
Build & Publish Package / publish (push) Successful in 50s
Testing / test (push) Successful in 31s
Package Extension / package-extension (push) Successful in 27s
Exposes a local browser over a TCP socket so remote machines can
  control it using the same CLI and Python API. Token auth (auto-generated
  via secrets.token_urlsafe) is on by default; --no-auth disables it.
  Profile routing via _route message field lets clients target specific
  browser instances on the remote host. BROWSER_CLI_PROFILE is forwarded
  automatically so --browser flag works transparently over remote.
  - browser-cli serve [--host] [--port] [--token] [--no-auth]
  - browser-cli --remote HOST:PORT --token TOKEN <command>
  - BrowserCLI(remote="host:port", token="...").tabs_list()
2026-04-25 18:33:59 +02:00
daniel156161 1bf44c0eef update uv lock file
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 14s
Build & Publish Package / publish (push) Successful in 26s
2026-04-17 21:09:27 +02:00
daniel156161 cf0c9555d0 update version to 0.7.1
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 13s
Build & Publish Package / publish (push) Successful in 31s
2026-04-17 21:08:18 +02:00
daniel156161 a7da6cfab0 hardcode extension id and not prompt user
Testing / test (push) Has been cancelled
2026-04-17 21:07:30 +02:00
daniel156161 88b4f5ed11 add key generation script 2026-04-17 21:06:52 +02:00
49 changed files with 4499 additions and 1909 deletions
+17 -2
View File
@@ -14,6 +14,18 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install extension build dependencies
run: npm ci
- name: Build extension
run: npm run check:extension
- name: Read extension version
id: version
run: |
@@ -29,8 +41,11 @@ jobs:
- name: Build extension archive
run: |
mkdir -p dist
cd extension
rm -rf extension-package
mkdir -p dist extension-package
cp extension/manifest.json extension/background.js extension/content.js extension/icon.svg extension-package/
cp -R extension/icons extension-package/icons
cd extension-package
zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" .
- name: Publish extension release asset
+9 -2
View File
@@ -1,4 +1,11 @@
__pycache__/
.vscode/
# TypeScript / Node
extension/background.js
node_modules/
dist/
# Python
__pycache__/
*.pyc
# IDE
.vscode/
+22 -2
View File
@@ -95,8 +95,9 @@ browser-cli/
│ └── session.py # session save/load
├── extension/
│ ├── manifest.json # MV3 extension manifest
│ ├── background.js # Service worker command dispatcher
│ └── content.js # Content-script helpers
│ ├── content.js # Content-script helpers
│ └── src/ # TypeScript source split by command area
│ └── index.ts # Builds generated extension/background.js
├── examples/
│ ├── demo.py # Python API walkthrough
│ └── demo.sh # Bash CLI walkthrough
@@ -402,6 +403,25 @@ bash examples/demo.sh
---
## Development
```sh
npm ci
npm run check:extension # type-check, build extension/background.js, syntax-check bundle
uv run pytest -q
```
On NixOS or hosts without global Node/npm:
```sh
nix-shell # automatically runs npm ci when node_modules is missing/outdated
npm run check:extension
```
The extension source lives in `extension/src/`. `extension/background.js` is generated and ignored by git. Run `npm run build:extension` before using `Load unpacked` with `extension/`. On NixOS, use `nix-shell` first if npm is not installed globally.
---
## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
+79 -29
View File
@@ -19,7 +19,7 @@ Usage:
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from browser_cli.models import Group, Tab
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
@@ -33,36 +33,52 @@ class BrowserCounts:
class BrowserCLI:
def __init__(self, browser: str | None = None):
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
"""
Args:
browser: Profile alias to target. Required when multiple browser
instances are active. Equivalent to ``--browser`` on the CLI.
remote: Connect to a remote browser exposed via ``browser-cli serve``.
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
Can be combined with ``browser`` to route to a specific
remote profile.
key: Path to Ed25519 private key PEM for pubkey auth, or ``"agent"``
to use a key from the SSH agent (YubiKey, gpg-agent, etc.).
Defaults to ``~/.config/browser-cli/client.key.pem`` if that file exists.
"""
self._browser = browser
self._remote = remote
self._key = key if key else None
def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser)
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def _multi_browser_targets(self):
if self._browser is not None:
return []
targets = active_browser_targets()
if len(targets) <= 1:
if self._remote:
targets = remote_browser_targets(self._remote, key=self._key)
else:
targets = active_browser_targets()
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
def _collect_multi_browser(self, command: str, args: dict | None = None):
results = []
for target in self._multi_browser_targets():
targets = self._multi_browser_targets()
for target in targets:
try:
data = send_command(command, args, profile=target.profile)
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
else:
data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError):
continue
results.append((target, data))
if results:
return results
if self._multi_browser_targets():
if targets:
raise BrowserNotConnected(
"Cannot resolve a browser socket automatically.\n"
"Make sure the browser is running with the browser-cli extension enabled,\n"
@@ -72,7 +88,14 @@ class BrowserCLI:
# ── Internal factories ────────────────────────────────────────────────
def _make_tab(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Tab:
def _make_tab(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Tab:
tab = Tab(
id=data["id"],
window_id=data.get("windowId", 0),
@@ -83,10 +106,20 @@ class BrowserCLI:
group_id=data.get("groupId") or None,
browser=browser_name,
)
tab._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
)
return tab
def _make_group(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Group:
def _make_group(
self,
data: dict,
*,
browser_profile: str | None = None,
browser_name: str | None = None,
browser_remote: str | None = None,
) -> Group:
group = Group(
id=data["id"],
title=data.get("title") or "",
@@ -95,7 +128,10 @@ class BrowserCLI:
tab_count=data.get("tabCount", 0),
browser=browser_name,
)
group._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile)
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
)
return group
# ── Navigation ────────────────────────────────────────────────────────
@@ -136,7 +172,9 @@ class BrowserCLI:
"url": url, "timeout": int(timeout * 1000),
"background": background, "window": window, "group": group,
})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open_wait returned unexpected data")
return self._make_tab(data)
def wait_for_load(
self,
@@ -187,7 +225,12 @@ class BrowserCLI:
multi_results = self._collect_multi_browser("tabs.list", {})
if multi_results:
return [
self._make_tab(tab, browser_profile=target.profile, browser_name=target.display_name)
self._make_tab(
tab,
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
)
for target, tabs in multi_results
for tab in (tabs or [])
]
@@ -248,7 +291,9 @@ class BrowserCLI:
) -> "Tab":
"""Block until the tab URL matches regex pattern. Returns the Tab."""
data = self._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)})
return self._make_tab(data) if isinstance(data, dict) and "id" in data else data
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("tabs.watch_url returned unexpected data")
return self._make_tab(data)
def tabs_screenshot(
self,
@@ -334,7 +379,12 @@ class BrowserCLI:
multi_results = self._collect_multi_browser("group.list", {})
if multi_results:
return [
self._make_group(group, browser_profile=target.profile, browser_name=target.display_name)
self._make_group(
group,
browser_profile=target.profile,
browser_name=target.display_name,
browser_remote=target.remote,
)
for target, groups in multi_results
for group in (groups or [])
]
@@ -391,7 +441,7 @@ class BrowserCLI:
for target, windows in multi_results
for window in (windows or [])
]
return self._cmd("windows.list", {})
return self._cmd("windows.list", {}) or []
def windows_rename(self, window_id: int, name: str) -> None:
self._cmd("windows.rename", {"windowId": window_id, "name": name})
@@ -401,12 +451,12 @@ class BrowserCLI:
def windows_open(self, url: str | None = None) -> dict:
"""Open a new browser window, optionally on a URL."""
return self._cmd("windows.open", {"url": url})
return self._cmd("windows.open", {"url": url}) or {}
# ── DOM ───────────────────────────────────────────────────────────────
def dom_query(self, selector: str) -> list[dict]:
return self._cmd("dom.query", {"selector": selector})
return self._cmd("dom.query", {"selector": selector}) or []
def dom_click(self, selector: str) -> None:
self._cmd("dom.click", {"selector": selector})
@@ -415,13 +465,13 @@ class BrowserCLI:
self._cmd("dom.type", {"selector": selector, "text": text})
def dom_attr(self, selector: str, attr: str) -> list[str]:
return self._cmd("dom.attr", {"selector": selector, "attr": attr})
return self._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
def dom_text(self, selector: str) -> list[str]:
return self._cmd("dom.text", {"selector": selector})
return self._cmd("dom.text", {"selector": selector}) or []
def dom_exists(self, selector: str) -> bool:
return self._cmd("dom.exists", {"selector": selector})
return self._cmd("dom.exists", {"selector": selector}) or False
def dom_scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
"""Scroll to a CSS selector or to pixel coordinates."""
@@ -581,13 +631,13 @@ class BrowserCLI:
# ── Extract ───────────────────────────────────────────────────────────
def extract_links(self) -> list[dict]:
return self._cmd("extract.links", {})
return self._cmd("extract.links", {}) or []
def extract_images(self) -> list[dict]:
return self._cmd("extract.images", {})
return self._cmd("extract.images", {}) or []
def extract_text(self) -> str:
return self._cmd("extract.text", {})
return self._cmd("extract.text", {}) or ""
def extract_json(self, selector: str):
return self._cmd("extract.json", {"selector": selector})
@@ -604,7 +654,7 @@ class BrowserCLI:
self._cmd("session.load", {"name": name})
def session_diff(self, name_a: str, name_b: str) -> dict:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b})
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
def session_list(self) -> list[dict]:
"""Return saved sessions.
@@ -618,7 +668,7 @@ class BrowserCLI:
for target, sessions in multi_results
for session in (sessions or [])
]
return self._cmd("session.list", {})
return self._cmd("session.list", {}) or []
def session_remove(self, name: str) -> None:
self._cmd("session.remove", {"name": name})
@@ -636,8 +686,8 @@ class BrowserCLI:
try:
transformed = filter_fn(tabs)
except Exception:
transformed = None
except (AttributeError, TypeError):
return [tab for tab in tabs if filter_fn(tab)]
if isinstance(transformed, list):
return transformed
+214
View File
@@ -0,0 +1,214 @@
"""Ed25519 keypair management and challenge-response 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.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
load_pem_private_key,
)
_CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli"
DEFAULT_KEY_PATH = _CONFIG_DIR / "client.key.pem"
DEFAULT_AUTHORIZED_KEYS_PATH = _CONFIG_DIR / "authorized_keys"
# ── SSH agent protocol constants ───────────────────────────────────────────────
_SSH_AGENTC_REQUEST_IDENTITIES = 11
_SSH_AGENT_IDENTITIES_ANSWER = 12
_SSH_AGENTC_SIGN_REQUEST = 13
_SSH_AGENT_SIGN_RESPONSE = 14
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 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 fields."""
return json.dumps(
{k: v for k, v in msg.items() if k not in {"pubkey", "sig"}},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def sign(key: Ed25519PrivateKey | AgentKey, nonce: bytes, msg: dict) -> bytes:
"""Sign nonce + SHA256(canonical_payload(msg)) — works for both file keys and agent keys."""
data = nonce + hashlib.sha256(canonical_payload(msg)).digest()
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) -> bool:
"""Return True if sig_hex is a valid Ed25519 signature over the canonical payload."""
try:
pub_bytes = bytes.fromhex(pub_hex)
pub_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
message = nonce + hashlib.sha256(canonical_payload(msg)).digest()
pub_key.verify(bytes.fromhex(sig_hex), message)
return True
except (InvalidSignature, Exception):
return False
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
+273 -59
View File
@@ -6,7 +6,6 @@ import click
import sys
import os
import json
import stat
import shutil
import re
from importlib.metadata import PackageNotFoundError, version as package_version
@@ -24,18 +23,37 @@ from browser_cli.commands.search import search_group
from browser_cli.commands.page import page_group
from browser_cli.commands.storage import storage_group
from browser_cli.commands.cookies import cookies_group
from browser_cli.commands.serve import cmd_serve
from browser_cli.client import (
send_command,
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
remote_target_for_alias,
remote_browser_targets,
)
from browser_cli.platform import install_base_dir, is_windows
from browser_cli.registry import load_registry
console = Console()
# Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
# patch to use a wider limit so zsh completion descriptions aren't truncated.
def _patched_group_shell_complete(self, ctx, incomplete):
from click.shell_completion import CompletionItem
results = [
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
for name, command in self.commands.items()
if not command.hidden and name.startswith(incomplete)
]
results.extend(click.Command.shell_complete(self, ctx, incomplete))
return results
click.Group.shell_complete = _patched_group_shell_complete
NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
NATIVE_HOST_DIRS = {
"chrome": {
@@ -82,26 +100,31 @@ def _rename_target_profile(target_browser: str | None) -> str | None:
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
target_profile = _rename_target_profile(target_browser)
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
try:
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
profiles = {}
profiles: dict[str, str] = load_registry(REGISTRY_PATH)
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_wrapper_path() -> Path:
base_dir = install_base_dir()
def _native_host_exe() -> Path:
base = install_base_dir()
if is_windows():
return base_dir / "libexec" / "native-host.cmd"
return base_dir / "libexec" / "native-host"
return base / "libexec" / "browser-cli-native-host.cmd"
return base / "libexec" / "browser-cli-native-host"
def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py")
def _write_native_host_exe(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(
f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n'
)
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views():
@@ -163,16 +186,183 @@ def _print_version(ctx, param, value):
"--browser", default=None, metavar="ALIAS",
help="Browser profile alias to target (required when multiple browsers are active).",
)
@click.option(
"--remote", default=None, metavar="HOST:PORT",
help="Connect to a remote browser exposed via 'browser-cli serve'.",
)
@click.option(
"--key", default=None, metavar="PATH",
help="Ed25519 private key PEM for pubkey auth with a remote serve instance.",
)
@click.pass_context
def main(ctx, browser):
def main(ctx, browser, remote, key):
"""Control your running browser from the terminal via a Chrome extension."""
ctx.ensure_object(dict)
ctx.obj["browser"] = browser
ctx.obj["browser_explicit"] = browser is not None
if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_PROFILE", None))
ctx.obj["remote"] = remote
ctx.obj["key"] = key
if remote:
os.environ["BROWSER_CLI_REMOTE"] = remote
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_REMOTE", None))
if key:
os.environ["BROWSER_CLI_KEY"] = key
ctx.call_on_close(lambda: os.environ.pop("BROWSER_CLI_KEY", None))
# ── auth ──────────────────────────────────────────────────────────────────────
@click.group("auth")
def auth_group():
"""Manage Ed25519 keys for public-key authentication with browser-cli serve."""
@auth_group.command("keygen")
@click.option("--output", "-o", default=None, metavar="PATH", help="Output path for the private key PEM.")
@click.option("--force", is_flag=True, help="Overwrite existing key.")
def cmd_auth_keygen(output, force):
"""Generate an Ed25519 keypair for pubkey auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, generate_keypair
key_path = Path(output) if output else DEFAULT_KEY_PATH
if key_path.exists() and not force:
console.print(f"[red]Key already exists:[/red] {key_path} (use --force to overwrite)")
sys.exit(1)
pem, pub_hex = generate_keypair()
key_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(key_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(pem)
console.print(f"[green]✓[/green] Private key: {key_path}")
console.print(f"\nPublic key:\n [bold cyan]{pub_hex}[/bold cyan]")
console.print(f"\nOn the serve host, trust this key:")
console.print(f" [dim]browser-cli auth trust {pub_hex}[/dim]")
@auth_group.command("trust")
@click.argument("pubkey")
@click.option("--name", default="", metavar="NAME", help="Human-friendly label for this key.")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_trust(ctx, pubkey, name, keys_file):
"""Add a public key to the authorized keys file (locally or on a remote serve host)."""
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, add_authorized_key
if len(pubkey) != 64:
console.print("[red]Invalid public key:[/red] expected 64 hex characters (Ed25519 raw public key)")
sys.exit(1)
try:
bytes.fromhex(pubkey)
except ValueError:
console.print("[red]Invalid public key:[/red] not valid hex")
sys.exit(1)
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.trust",
args={"pubkey": pubkey, "name": name},
remote=remote,
key=(ctx.obj or {}).get("key"),
)
added = (result or {}).get("added", False)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted on {remote}{label}: [cyan]{pubkey}[/cyan]")
else:
console.print(f"[yellow]Already trusted on {remote}:[/yellow] {pubkey}")
return
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
added = add_authorized_key(path, pubkey, name)
label = f" ({name})" if name else ""
if added:
console.print(f"[green]✓[/green] Trusted{label}: [cyan]{pubkey}[/cyan]")
console.print(f" File: {path}")
console.print(f"\nStart the server with:")
console.print(f" [dim]browser-cli serve --authorized-keys {path}[/dim]")
else:
console.print(f"[yellow]Already trusted:[/yellow] {pubkey}")
@auth_group.command("show")
@click.option("--key", "key_src", default=None, metavar="PATH|agent[:<selector>]",
help="Key source: path to PEM file, 'agent', or 'agent:<comment-filter>'.")
def cmd_auth_show(key_src):
"""Print the Ed25519 public key that browser-cli will use for auth."""
from browser_cli.auth import DEFAULT_KEY_PATH, agent_find_key, load_private_key, public_key_hex
src = key_src or os.environ.get("BROWSER_CLI_KEY", str(DEFAULT_KEY_PATH))
if src == "agent" or src.startswith("agent:"):
selector = src[6:] or None
key = agent_find_key(selector)
if key is None:
console.print("[red]No Ed25519 key found in SSH agent.[/red]")
console.print(" Make sure gpg-agent / ssh-agent is running and the key is loaded.")
sys.exit(1)
console.print(f"[dim]source:[/dim] agent ({key.comment})")
console.print(public_key_hex(key))
return
path = Path(src)
if not path.exists():
console.print(f"[red]No key found at {path}[/red]")
console.print(" Run: [dim]browser-cli auth keygen[/dim]")
console.print(" Or use: [dim]browser-cli auth show --key agent[/dim]")
sys.exit(1)
try:
priv = load_private_key(path)
console.print(f"[dim]source:[/dim] {path}")
console.print(public_key_hex(priv))
except Exception as e:
console.print(f"[red]Failed to load key:[/red] {e}")
sys.exit(1)
@auth_group.command("keys")
@click.option("--file", "keys_file", default=None, metavar="PATH", help="Authorized keys file (default: ~/.config/browser-cli/authorized_keys).")
@click.pass_context
def cmd_auth_keys(ctx, keys_file):
"""List trusted public keys (server's authorized_keys). With --remote, queries the remote server."""
from rich.table import Table
remote = (ctx.obj or {}).get("remote")
if remote:
from browser_cli.client import send_command
result = send_command(
"browser-cli.auth.keys",
remote=remote,
key=(ctx.obj or {}).get("key"),
)
entries = result or []
source_label = remote
else:
from browser_cli.auth import DEFAULT_AUTHORIZED_KEYS_PATH, load_authorized_keys_with_names
path = Path(keys_file) if keys_file else DEFAULT_AUTHORIZED_KEYS_PATH
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(path)]
source_label = str(path)
if not entries:
console.print(f"[yellow]No trusted keys[/yellow] in {source_label}")
console.print(" Add one: [dim]browser-cli auth trust <public-key> --name <label>[/dim]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Name")
table.add_column("Public Key")
for entry in entries:
name = entry.get("name") or "[dim]—[/dim]"
table.add_row(name, entry.get("pubkey", ""))
console.print(table)
main.add_command(auth_group)
# ── Sub-command groups ─────────────────────────────────────────────────────────
main.add_command(nav_group)
main.add_command(tabs_group)
@@ -185,6 +375,7 @@ main.add_command(search_group)
main.add_command(page_group)
main.add_command(storage_group)
main.add_command(cookies_group)
main.add_command(cmd_serve)
# ── clients ────────────────────────────────────────────────────────────────────
@@ -196,29 +387,70 @@ def clients_group(ctx):
if ctx.invoked_subcommand is not None:
return
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
try:
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
pass
all_clients = []
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
key = (ctx.obj or {}).get("key")
if not remote and browser_alias:
# --browser <host> without --remote: resolve host alias to a remote endpoint,
# then show ALL clients from that remote (not just the one resolved profile).
resolved = remote_target_for_alias(browser_alias)
if resolved:
try:
targets = remote_browser_targets(resolved.remote)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
for target in targets:
try:
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, key=key)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
continue
elif remote:
try:
result = send_command("clients.list", profile=profile_name)
result = send_command("clients.list", profile=browser_alias, remote=remote, key=key)
for c in (result or []):
c["profile"] = display_profile
c["profile"] = c.get("profile") or browser_alias or "remote"
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
# Socket registered but browser no longer connected
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
else:
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
profiles = load_registry(REGISTRY_PATH)
for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path)
try:
result = send_command("clients.list", profile=profile_name)
for c in (result or []):
c["profile"] = display_profile
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
# Socket registered but browser no longer connected
all_clients.append({
"profile": display_profile,
"name": "",
"version": "",
"extensionVersion": "disconnected",
})
for target in active_browser_targets():
if target.remote is None:
continue
try:
result = send_command("clients.list", profile=target.profile, remote=target.remote)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
continue
if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
@@ -267,24 +499,10 @@ def cmd_clients_rename(target_browser, alias):
def cmd_install(browser):
"""Register the native messaging host and print extension load instructions."""
# Install wrapper outside PATH — the browser uses the absolute path from the
# native messaging manifest, so only `browser-cli` needs to be on PATH.
wrapper_path = _native_host_wrapper_path()
native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
if not is_windows():
native_host_script_path.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
host_exe = _native_host_exe()
_write_native_host_exe(host_exe)
# Ask for extension ID
# Load extension
ext_urls = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
@@ -297,23 +515,22 @@ def cmd_install(browser):
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]")
console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n")
console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
extension_id = click.prompt("Paste your extension ID here")
extension_id = extension_id.strip()
extension_id = EXTENSION_ID
# Build native messaging manifest
manifest = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(wrapper_path),
"path": str(host_exe),
"type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/"],
}
installed = []
if is_windows():
manifest_dir = wrapper_path.parent
manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
@@ -340,10 +557,7 @@ def cmd_install(browser):
console.print(f"[green]✓[/green] Registered native host: {p}")
else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
+256 -19
View File
@@ -19,8 +19,17 @@ from pathlib import Path
from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.registry import load_registry
try:
from importlib.metadata import version as _pkg_version
_USER_AGENT = f"browser-cli/{_pkg_version('browser-cli')}"
except Exception:
_USER_AGENT = "browser-cli/0"
REGISTRY_PATH = registry_path()
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
_DEFAULT_KEY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "browser-cli" / "client.key.pem"
class BrowserNotConnected(Exception):
@@ -32,6 +41,7 @@ class BrowserTarget:
profile: str
display_name: str
socket_path: str
remote: str | None = None
def _active_endpoints(reg: dict) -> dict:
@@ -47,17 +57,121 @@ def display_browser_name(profile_name: str, sock_path: str) -> str:
return Path(sock_path).stem or profile_name
def active_browser_targets() -> list[BrowserTarget]:
if not REGISTRY_PATH.exists():
return []
def _load_remotes() -> dict[str, dict[str, str]]:
if not REMOTE_REGISTRY_PATH.exists():
return {}
try:
reg = json.loads(REGISTRY_PATH.read_text())
data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception:
return []
return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_endpoints(reg).items()
]
return {}
if not isinstance(data, dict):
return {}
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def _is_valid_key_spec(s: str) -> bool:
"""Return True if s looks like a usable key spec: 'agent', 'agent:<sel>', or a file path."""
return s == "agent" or s.startswith("agent:") or (not s.startswith("<") and "/" in s or Path(s).suffix in {".pem", ".key"})
def save_remote_key(endpoint: str, key_spec: str) -> None:
"""Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
if not endpoint or not key_spec:
return
if not _is_valid_key_spec(key_spec):
return # refuse to save serialized objects or other garbage
remotes = _load_remotes()
current = remotes.get(endpoint, {})
current["key"] = key_spec
remotes[endpoint] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(json.dumps(remotes, indent=2, sort_keys=True))
def key_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = _load_remotes().get(endpoint) or {}
key = cfg.get("key")
if not key:
return None
key_str = str(key)
# reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug)
if not _is_valid_key_spec(key_str):
return None
return key_str
def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> str:
host, sep, port = endpoint.rpartition(":")
remote_name = host if sep and port == "8765" else endpoint
return f"{remote_name}:{display_name or profile_name}"
def remote_browser_targets(endpoint: str, key=None) -> list[BrowserTarget]:
"""Return browser targets advertised by a single remote endpoint."""
remote_targets = send_command("browser-cli.targets", remote=endpoint, key=key)
targets: list[BrowserTarget] = []
for item in remote_targets or []:
profile = str(item.get("profile") or "default")
display = str(item.get("displayName") or profile)
targets.append(
BrowserTarget(
profile=profile,
display_name=_remote_display_name(endpoint, profile, display),
socket_path="",
remote=endpoint,
)
)
return targets
def _remote_browser_targets(key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint in _load_remotes():
try:
targets.extend(remote_browser_targets(endpoint, key=key))
except (BrowserNotConnected, RuntimeError):
continue
return targets
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
if not alias:
return None
targets = _remote_browser_targets()
for target in targets:
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
if alias in {target.display_name, endpoint_profile}:
return target
endpoint_matches = []
for target in targets:
if not target.remote:
continue
remote_host, sep, _remote_port = target.remote.rpartition(":")
if alias == target.remote or (sep and alias == remote_host):
endpoint_matches.append(target)
if len(endpoint_matches) == 1:
return endpoint_matches[0]
return None
def active_browser_targets(*, include_remotes: bool = True, key=None) -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
if REGISTRY_PATH.exists():
reg = load_registry(REGISTRY_PATH)
targets.extend(
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_endpoints(reg).items()
)
if include_remotes:
targets.extend(_remote_browser_targets(key=key))
return targets
def _resolve_socket(profile: str | None = None) -> str:
@@ -66,17 +180,14 @@ def _resolve_socket(profile: str | None = None) -> str:
if target:
if REGISTRY_PATH.exists():
try:
reg = json.loads(REGISTRY_PATH.read_text())
if target in reg:
return reg[target]
except Exception:
pass
reg = load_registry(REGISTRY_PATH)
if target in reg:
return reg[target]
return endpoint_for_alias(target)
# Auto-detect: error when multiple browser instances are active
try:
active = active_browser_targets()
active = active_browser_targets(include_remotes=False)
if len(active) > 1:
aliases = [target.profile for target in active]
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
@@ -98,28 +209,147 @@ def _resolve_socket(profile: str | None = None) -> str:
)
def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any:
def _load_private_key(key_path: "Path | str | None" = None):
"""Load an Ed25519 signing key.
Sources (in priority order):
1. Explicit key_path / --key flag
2. BROWSER_CLI_KEY environment variable
3. Default PEM file (~/.config/browser-cli/client.key.pem)
Pass "agent" or "agent:<selector>" to use a key from the SSH agent
(works with YubiKey via gpg-agent, TPM, or regular ssh-agent).
"""
raw = str(key_path) if key_path is not None else os.environ.get("BROWSER_CLI_KEY", str(_DEFAULT_KEY_PATH))
if raw == "agent" or raw.startswith("agent:"):
selector = raw[6:] or None # "agent:cardno:..." → "cardno:..."
from browser_cli.auth import agent_find_key
return agent_find_key(selector)
path = Path(raw)
if not path.exists():
return None
try:
from browser_cli.auth import load_private_key
return load_private_key(path)
except Exception:
return None
def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
host, _, port_str = endpoint.rpartition(":")
if not host or not port_str:
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(30)
sock.connect((host, int(port_str)))
# receive challenge
challenge_raw = _recv_all(sock)
if challenge_raw is None:
raise BrowserNotConnected(f"No challenge received from {endpoint}")
try:
challenge = json.loads(challenge_raw)
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
except (json.JSONDecodeError, AttributeError):
nonce_hex = None
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
if min_ver:
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
if nonce_hex and private_key is not None:
from browser_cli.auth import sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig"}}
sig = sign(private_key, nonce, clean_msg)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
return _recv_all(sock)
def _auto_route_remote(endpoint: str, key=None) -> str | None:
targets = remote_browser_targets(endpoint, key=key)
if len(targets) == 1:
return targets[0].profile
if len(targets) > 1:
aliases = [target.profile for target in targets]
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
raise BrowserNotConnected(
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
f"Use --browser <alias> to select one:\n{examples}"
)
return None
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, key: "Path | None" = None) -> Any:
"""Send a command to the browser and return the response data."""
sock_path = _resolve_socket(profile)
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
remote_alias_target = None
if not remote_endpoint and requested_profile:
remote_alias_target = remote_target_for_alias(requested_profile)
if remote_alias_target:
remote_endpoint = remote_alias_target.remote
requested_profile = remote_alias_target.profile
msg = {
"id": str(uuid.uuid4()),
"command": command,
"args": args or {},
}
if remote_endpoint:
msg["user_agent"] = _USER_AGENT
# key priority: explicit flag > saved per-remote config > BROWSER_CLI_KEY env > default file
key_spec = key if key is not None else key_for_remote(remote_endpoint)
private_key = _load_private_key(key_spec)
# persist explicit key spec so future calls don't need --key
if key is not None:
save_remote_key(remote_endpoint, str(key))
route_profile = requested_profile
_no_route_commands = {"browser-cli.targets", "browser-cli.auth.keys", "browser-cli.auth.trust"}
if not route_profile and command not in _no_route_commands:
route_profile = _auto_route_remote(remote_endpoint, key=key_spec)
if route_profile:
msg["_route"] = route_profile
else:
private_key = None
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
try:
if is_windows():
if remote_endpoint:
response = _send_remote(remote_endpoint, msg, private_key)
elif is_windows():
sock_path = _resolve_socket(profile)
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
sock_path = _resolve_socket(profile)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
if remote_endpoint:
raise BrowserNotConnected(
f"Cannot connect to remote browser at {remote_endpoint}.\n"
"Make sure browser-cli serve is running on the remote host."
)
profile_hint = f" (profile: {profile})" if profile else ""
raise BrowserNotConnected(
f"Cannot connect to browser{profile_hint}.\n"
@@ -130,15 +360,22 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
" Tip: use BROWSER_CLI_PROFILE=<name> to select a specific profile"
)
if response is None:
raise ConnectionError("Connection closed before full response received")
result = json.loads(response)
if not result.get("success", True):
raise RuntimeError(result.get("error", "unknown error from browser"))
return result.get("data")
_MAX_MSG_BYTES = 32 * 1024 * 1024
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
if msg_len > _MAX_MSG_BYTES:
raise ConnectionError(f"Response too large ({msg_len} bytes)")
return _recv_exact(sock, msg_len)
+13 -6
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
from rich.table import Table
@@ -17,8 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -28,8 +30,13 @@ def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -71,7 +78,7 @@ def group_list():
if targets:
groups = []
for target in targets:
result = _handle_multi("group.list", profile=target.profile)
result = _handle_multi("group.list", profile=target.profile, remote=target.remote)
if result is None:
continue
groups.extend({**group, "browser": target.display_name} for group in result)
@@ -104,7 +111,7 @@ def group_count():
total = 0
rows = 0
for target in targets:
count = _handle_multi("group.count", profile=target.profile)
count = _handle_multi("group.count", profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
+271
View File
@@ -0,0 +1,271 @@
import re, threading, secrets, socket, struct, click, json, sys, os
from pathlib import Path
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version, get_installed_version
from browser_cli.compat import adapt_request, adapt_response
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
_MAX_MSG_BYTES = 32 * 1024 * 1024
from rich.console import Console
from datetime import datetime
console = Console()
def _recv_exact(sock:socket.socket, n:int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed")
buf += chunk
return buf
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
client_sock.sendall(struct.pack("<I", len(err)) + err)
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > _MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
_send_error(None, "invalid JSON")
_log(addr, "?", None, "ERROR", "invalid JSON")
return
msg_id = msg.get("id")
command = msg.get("command", "?")
# ── user-agent + version check ────────────────────────────────────────────
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, command, None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, command, None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
sig = msg.get("sig") or ""
if not pub or not sig:
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
_log(addr, command, None, "DENIED", "missing pubkey/sig")
return
if pub not in auth_keys:
_send_error(msg_id, "unauthorized: untrusted public key")
_log(addr, command, None, "DENIED", "untrusted key")
return
from browser_cli.auth import verify
if not verify(pub, bytes.fromhex(nonce), msg, sig):
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
data = json.dumps({"id": msg_id, "success": True, "data": targets}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.keys":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
data = json.dumps({"id": msg_id, "success": True, "data": entries}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.trust":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import add_authorized_key
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
if len(pubkey) != 64:
_send_error(msg_id, "invalid pubkey: expected 64 hex characters")
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
data = json.dumps({"id": msg_id, "success": True, "data": {"added": added}}).encode()
client_sock.sendall(struct.pack("<I", len(data)) + data)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
clean_header = struct.pack("<I", len(clean_payload))
try:
sock_path = _resolve_socket(resolved_profile)
except BrowserNotConnected as e:
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
return
try:
if is_windows():
from multiprocessing.connection import Client as PipeClient
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp = pipe.recv_bytes()
resp = adapt_response(resp, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_header = _recv_exact(local, 4)
resp_len = struct.unpack("<I", resp_header)[0]
resp_payload = _recv_exact(local, resp_len)
resp_payload = adapt_response(resp_payload, command, client_ver)
client_sock.sendall(struct.pack("<I", len(resp_payload)) + resp_payload)
resp_data = json.loads(resp_payload if not is_windows() else resp)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
except (OSError, json.JSONDecodeError) as e:
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None") -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
client_sock.settimeout(30)
try:
with client_sock:
# reload on every connection so auth trust --remote takes effect immediately
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
else:
auth_keys = None
nonce = secrets.token_hex(32)
challenge = json.dumps({
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}).encode()
try:
client_sock.sendall(struct.pack("<I", len(challenge)) + challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce)
finally:
_CONN_LIMIT.release()
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
if host in ("0.0.0.0", "::"):
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
elif no_auth:
auth_keys_path = None
else:
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind((host, port))
except OSError as e:
server.close()
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
server.listen(16)
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
console.print("Ctrl-C to stop.\n")
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
server.close()
+12 -5
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.client import active_browser_targets, send_command, BrowserNotConnected
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, BrowserNotConnected
from rich.console import Console
console = Console()
@@ -16,8 +16,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -27,8 +29,13 @@ def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -92,7 +99,7 @@ def session_list():
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile)
result = _handle_multi("session.list", profile=target.profile, remote=target.remote)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
+20 -7
View File
@@ -1,6 +1,7 @@
import base64
import binascii
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
from rich.table import Table
@@ -18,8 +19,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -29,8 +32,13 @@ def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -76,7 +84,7 @@ def tabs_list():
if targets:
tabs = []
for target in targets:
result = _handle_multi("tabs.list", profile=target.profile)
result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote)
if result is None:
continue
tabs.extend({**tab, "browser": target.display_name} for tab in result)
@@ -163,7 +171,7 @@ def tabs_count(pattern):
total = 0
rows = 0
for target in targets:
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile)
count = _handle_multi("tabs.count", {"pattern": pattern}, profile=target.profile, remote=target.remote)
if count is None:
continue
count = int(count or 0)
@@ -282,7 +290,12 @@ def tabs_screenshot(output, tab_id, fmt, quality):
data_url = result.get("dataUrl", "") if isinstance(result, dict) else ""
if output:
header = f"data:image/{fmt};base64,"
raw = base64.b64decode(data_url[len(header):])
if not data_url.startswith(header):
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
try:
raw = base64.b64decode(data_url[len(header):])
except binascii.Error as e:
raise click.ClickException(f"Failed to decode screenshot data: {e}")
with open(output, "wb") as f:
f.write(raw)
console.print(f"[green]Screenshot saved:[/green] {output}")
+12 -5
View File
@@ -1,5 +1,5 @@
import click
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
from rich.console import Console
from rich.table import Table
@@ -17,8 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None):
def _handle_multi(command, args=None, profile=None, remote=None):
try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote)
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
@@ -28,8 +30,13 @@ def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
remote = root.obj.get("remote")
key = root.obj.get("key")
if remote:
targets = remote_browser_targets(remote, key=key)
else:
targets = active_browser_targets(key=key)
if len(targets) <= 1 and not any(target.remote for target in targets):
return []
return targets
@@ -69,7 +76,7 @@ def windows_list():
if targets:
windows = []
for target in targets:
result = _handle_multi("windows.list", profile=target.profile)
result = _handle_multi("windows.list", profile=target.profile, remote=target.remote)
if result is None:
continue
windows.extend({**window, "browser": target.display_name} for window in result)
+49
View File
@@ -0,0 +1,49 @@
"""
Stripe-style version compatibility layer for browser-cli serve.
When a behaviour-breaking change ships in a new server version, add one entry
to _COMPAT below:
("X.Y.Z", request_fn, response_fn)
- ``request_fn(msg: dict) -> dict``
Upgrade an incoming client message from a client older than X.Y.Z to the
current format before forwarding it to the native host.
- ``response_fn(resp: bytes, command: str) -> bytes``
Downgrade a native-host response to the format a client older than X.Y.Z
expects before sending it back.
Either function may be ``None`` when only one direction needs adapting.
Entries must stay in ascending version order. ``adapt_request`` walks forward
(oldest change first); ``adapt_response`` walks backward (newest change first)
so the transformations compose correctly.
Current baseline: 0.9.1 no shims needed yet.
"""
from __future__ import annotations
from typing import Callable
from browser_cli.version_manager import parse_version
_COMPAT: list[tuple[str, Callable[[dict], dict] | None, Callable[[bytes, str], bytes] | None]] = [
# ("1.0.0", _req_1_0_0, _resp_1_0_0),
]
def adapt_request(msg: dict, client_version: str) -> dict:
"""Upgrade a client message to the current server format."""
cv = parse_version(client_version)
for version, req_fn, _ in _COMPAT:
if cv < parse_version(version) and req_fn is not None:
msg = req_fn(msg)
return msg
def adapt_response(resp: bytes, command: str, client_version: str) -> bytes:
"""Downgrade a server response to the format the client expects."""
cv = parse_version(client_version)
for version, _, resp_fn in reversed(_COMPAT):
if cv < parse_version(version) and resp_fn is not None:
resp = resp_fn(resp, command)
return resp
+136 -44
View File
@@ -7,6 +7,7 @@ It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import math
import os
import queue
import socket
@@ -18,21 +19,51 @@ from multiprocessing.connection import Listener
from pathlib import Path
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
from browser_cli.registry import update_registry
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
WRITE_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
PAGE_SIZE = int(os.environ.get("BROWSER_CLI_PAGE_SIZE", "100"))
PAGEABLE_COMMANDS = {
"tabs.list",
"tabs.filter",
"tabs.query",
"group.list",
"group.tabs",
"group.query",
"windows.list",
"dom.query",
"dom.text",
"dom.attr",
"extract.links",
"extract.images",
"extract.json",
"cookies.list",
"session.list",
}
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
def _read_exact_stream(stream, n: int) -> bytes | None:
buf = b""
while len(buf) < n:
chunk = stream.read(n - len(buf))
if not chunk:
return None # real EOF
buf += chunk
return buf
def read_native_message(stream) -> dict | None:
raw_len = stream.read(4)
if len(raw_len) < 4:
raw_len = _read_exact_stream(stream, 4)
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
data = stream.read(msg_len)
if len(data) < msg_len:
data = _read_exact_stream(stream, msg_len)
if data is None:
return None
return json.loads(data.decode("utf-8"))
@@ -48,20 +79,14 @@ def write_native_message(stream, msg: dict) -> None:
def _registry_add(alias: str, sock_path: str) -> None:
try:
reg = json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
reg[alias] = sock_path
REGISTRY_PATH.write_text(json.dumps(reg))
update_registry(alias, sock_path, REGISTRY_PATH)
except Exception:
pass
def _registry_remove(alias: str) -> None:
try:
if not REGISTRY_PATH.exists():
return
reg = json.loads(REGISTRY_PATH.read_text())
reg.pop(alias, None)
REGISTRY_PATH.write_text(json.dumps(reg))
update_registry(alias, None, REGISTRY_PATH)
except Exception:
pass
@@ -107,24 +132,32 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str):
def socket_server(sock_path: str, bound_sock: "socket.socket | None" = None):
if is_windows():
while True:
listener = None
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
if listener is not None:
try:
listener.close()
except Exception:
pass
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
path = Path(sock_path)
if path.exists():
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_path)
sock.listen(16)
sock = bound_sock
if sock is None:
path = Path(sock_path)
if path.exists():
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_path)
os.chmod(sock_path, 0o600)
sock.listen(16)
while True:
try:
@@ -143,21 +176,7 @@ def handle_cli_connection(conn, listener=None) -> None:
if "id" not in cmd:
cmd["id"] = str(uuid.uuid4())
msg_id = cmd["id"]
response_queue: queue.Queue = queue.Queue()
with PENDING_LOCK:
PENDING[msg_id] = response_queue
write_native_message(sys.stdout.buffer, cmd)
try:
result = response_queue.get(timeout=30)
except queue.Empty:
result = {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
with PENDING_LOCK:
PENDING.pop(msg_id, None)
result = _handle_browser_command(cmd)
response = json.dumps(result).encode("utf-8")
if is_windows():
@@ -179,6 +198,74 @@ def handle_cli_connection(conn, listener=None) -> None:
listener.close()
def _handle_browser_command(cmd: dict) -> dict:
command = cmd.get("command")
if command in PAGEABLE_COMMANDS:
return _collect_paged_browser_command(cmd)
return _send_browser_command(cmd)
def _send_browser_command(cmd: dict, timeout: int = 30) -> dict:
msg_id = cmd.get("id") or str(uuid.uuid4())
cmd["id"] = msg_id
response_queue: queue.Queue = queue.Queue()
with PENDING_LOCK:
PENDING[msg_id] = response_queue
try:
with WRITE_LOCK:
write_native_message(sys.stdout.buffer, cmd)
try:
return response_queue.get(timeout=timeout)
except queue.Empty:
return {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
finally:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
def _collect_paged_browser_command(cmd: dict) -> dict:
original_id = cmd.get("id") or str(uuid.uuid4())
offset = 0
items = []
total = None
max_pages = math.ceil(10_000 / PAGE_SIZE)
pages_fetched = 0
while True:
if pages_fetched >= max_pages:
return {"id": original_id, "success": False, "error": f"paging loop exceeded {max_pages} pages — extension bug?"}
pages_fetched += 1
page_cmd = dict(cmd)
page_cmd["id"] = str(uuid.uuid4())
page_args = dict(cmd.get("args") or {})
page_args["__page"] = {"offset": offset, "limit": PAGE_SIZE}
page_cmd["args"] = page_args
result = _send_browser_command(page_cmd)
result["id"] = original_id
if not result.get("success", True):
return result
data = result.get("data")
if not isinstance(data, dict) or data.get("__browserCliPage") is not True:
return result
page_items = data.get("items") or []
if not isinstance(page_items, list):
return {"id": original_id, "success": False, "error": "invalid paged response from browser"}
items.extend(page_items)
total = data.get("total", total)
next_offset = data.get("nextOffset")
if next_offset is None:
break
offset = int(next_offset)
return {"id": original_id, "success": True, "data": items, "pageSize": PAGE_SIZE, "total": total}
# --- Socket helpers (length-prefixed framing) ---
def _send_all(conn: socket.socket, data: bytes) -> None:
@@ -221,21 +308,26 @@ def main():
if first_msg and first_msg.get("type") == "hello":
alias = _resolve_profile_alias(first_msg)
else:
# No hello — use a generated alias and re-queue the first command if needed.
# No hello — use a generated alias; first_msg is dropped (no response path).
alias = str(uuid.uuid4())
if first_msg:
msg_id = first_msg.get("id")
if msg_id:
q: queue.Queue = queue.Queue()
with PENDING_LOCK:
PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg)
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
if not is_windows():
path = Path(sock_path)
if path.exists():
path.unlink()
bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
bound_sock.bind(sock_path)
os.chmod(sock_path, 0o600)
bound_sock.listen(16)
else:
bound_sock = None
_registry_add(alias, sock_path)
t = threading.Thread(target=socket_server, args=(sock_path,), daemon=True)
t = threading.Thread(target=socket_server, args=(sock_path,), kwargs={"bound_sock": bound_sock}, daemon=True)
t.start()
stdin_reader(alias)
+99
View File
@@ -0,0 +1,99 @@
"""Runtime registry helpers for active browser-cli native host endpoints."""
import contextlib
import json
import os
import tempfile
from pathlib import Path
from typing import Iterator
from browser_cli.platform import registry_path
REGISTRY_PATH = registry_path()
@contextlib.contextmanager
def _file_lock(path: Path) -> Iterator[None]:
"""Best-effort cross-process lock for registry read/modify/write updates."""
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
lock_path = path.with_suffix(path.suffix + ".lock")
with lock_path.open("a+") as lock_file:
if os.name == "nt":
try:
import msvcrt
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
yield
finally:
try:
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
try:
import fcntl
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError:
pass
def _coerce_registry(data) -> dict[str, str]:
if not isinstance(data, dict):
return {}
return {str(alias): str(endpoint) for alias, endpoint in data.items() if alias and endpoint}
def load_registry(path: Path | None = None) -> dict[str, str]:
"""Load the active browser registry.
Older native hosts wrote this file non-atomically, so tolerate trailing
garbage from interrupted/concurrent writes and keep the first valid JSON
object when possible.
"""
registry = path or REGISTRY_PATH
if not registry.exists():
return {}
try:
text = registry.read_text(encoding="utf-8")
except OSError:
return {}
if not text.strip():
return {}
try:
return _coerce_registry(json.loads(text))
except json.JSONDecodeError:
try:
data, _ = json.JSONDecoder().raw_decode(text)
return _coerce_registry(data)
except json.JSONDecodeError:
return {}
def save_registry(data: dict[str, str], path: Path | None = None) -> None:
"""Atomically write the active browser registry."""
registry = path or REGISTRY_PATH
registry.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
payload = json.dumps(_coerce_registry(data), sort_keys=True)
fd, tmp_name = tempfile.mkstemp(prefix=registry.name + ".", suffix=".tmp", dir=registry.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(payload)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp_name, registry)
finally:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
def update_registry(alias: str, endpoint: str | None, path: Path | None = None) -> None:
"""Add/update an alias, or remove it when endpoint is None."""
registry = path or REGISTRY_PATH
with _file_lock(registry):
data = load_registry(registry)
if endpoint is None:
data.pop(alias, None)
else:
data[alias] = endpoint
save_registry(data, registry)
+17
View File
@@ -0,0 +1,17 @@
from importlib.metadata import version as _pkg_version
PROTOCOL_MIN_CLIENT = "0.9.0"
def parse_version(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except ValueError:
return (0,)
def get_installed_version() -> str:
try:
return _pkg_version("browser-cli")
except Exception:
return "0.0.0"
-9
View File
@@ -1,9 +0,0 @@
{
"name": "com.browsercli.host",
"description": "browser-cli native messaging host",
"path": "/REPLACE_WITH_ABSOLUTE_PATH/browser-cli/libexec/native-host",
"type": "stdio",
"allowed_origins": [
"chrome-extension://REPLACE_WITH_EXTENSION_ID/"
]
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.7.0",
"version": "0.9.2",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+62
View File
@@ -0,0 +1,62 @@
// @ts-nocheck
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
export async function storageGet({ key, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, t) => {
const store = t === "session" ? sessionStorage : localStorage;
if (k) return store.getItem(k);
return Object.fromEntries(Object.keys(store).map(key => [key, store.getItem(key)]));
},
args: [key || null, type],
});
return results[0]?.result ?? null;
}
export async function storageSet({ key, value, type = "local", tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot access storage on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (k, v, t) => {
const store = t === "session" ? sessionStorage : localStorage;
store.setItem(k, typeof v === "string" ? v : JSON.stringify(v));
return true;
},
args: [key, value, type],
});
return results[0]?.result ?? false;
}
export async function cookiesList({ url, domain, name } = {}) {
const details = {};
if (url) details.url = url;
if (domain) details.domain = domain;
if (name) details.name = name;
return await chrome.cookies.getAll(details);
}
export async function cookiesGet({ url, name }) {
return await chrome.cookies.get({ url, name });
}
export async function cookiesSet({ url, name, value, domain, path, secure, httpOnly, expirationDate, sameSite } = {}) {
const details = { url, name, value };
if (domain != null) details.domain = domain;
if (path != null) details.path = path;
if (secure != null) details.secure = secure;
if (httpOnly != null) details.httpOnly = httpOnly;
if (expirationDate != null) details.expirationDate = expirationDate;
if (sameSite != null) details.sameSite = sameSite;
return await chrome.cookies.set(details);
}
// This function is serialized and injected into the page by chrome.scripting
+82
View File
@@ -0,0 +1,82 @@
// @ts-nocheck
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
import { contentDispatch } from './injected';
export async function domOp(funcName, args) {
const tab = await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
func: contentDispatch,
args: [funcName, args],
});
return results[0]?.result;
}
export async function domEval({ code, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (c) => (0, eval)(c),
args: [code],
});
return results[0]?.result ?? null;
}
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
const el = document.querySelector(sel);
if (hid) return !el || el.offsetParent === null;
if (!el) return false;
if (vis) {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
return true;
},
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
}
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
const el = document.querySelector(sel);
if (!el) return null;
if (a) return el.getAttribute(a) ?? el[a] ?? null;
return el.value !== undefined ? el.value : el.textContent.trim();
},
args: [selector, attr || null],
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
}
+95
View File
@@ -0,0 +1,95 @@
// @ts-nocheck
import { buildTabBlocks, resolveGroupId, tabInfo } from '../core';
export async function groupList() {
const groups = await chrome.tabGroups.query({});
const all = await chrome.tabs.query({});
return groups.map(g => ({
id: g.id,
title: g.title,
color: g.color,
collapsed: g.collapsed,
windowId: g.windowId,
tabCount: all.filter(t => t.groupId === g.id).length,
}));
}
export async function groupTabs({ groupId }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.groupId === groupId).map(tabInfo);
}
export async function groupCount() {
const groups = await chrome.tabGroups.query({});
return groups.length;
}
export async function groupQuery({ search }) {
const q = search.toLowerCase();
const groups = await chrome.tabGroups.query({});
return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
}
export async function groupClose({ groupId }) {
const tabs = await chrome.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId);
await chrome.tabs.ungroup(groupTabs.map(t => t.id));
return { groupId };
}
export async function groupOpen({ name }) {
const tab = await chrome.tabs.create({ active: true });
const groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: name });
return { id: groupId, name };
}
export async function groupAddTab({ group, url }) {
const groupId = await resolveGroupId(group);
const existingTabs = await chrome.tabs.query({ groupId });
const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true });
await chrome.tabs.group({ tabIds: [tab.id], groupId });
// If a URL was provided, close any blank placeholder tabs left from group creation
if (url) {
const placeholders = existingTabs.filter(t =>
t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/"
);
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
}
return { tabId: tab.id, groupId };
}
export async function groupMove({ group, forward, backward }) {
const groupId = await resolveGroupId(group);
const groupInfo = await chrome.tabGroups.get(groupId);
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index);
const blocks = buildTabBlocks(allTabs);
const currentIdx = blocks.findIndex(block => block.groupId === groupId);
if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`);
const currentBlock = blocks[currentIdx];
const currentLength = currentBlock.tabIds.length;
if (forward) {
const nextBlock = blocks[currentIdx + 1];
if (!nextBlock) return { groupId, moved: false };
const targetIndex =
nextBlock.groupId === null
? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex });
} else if (backward) {
const previousBlock = blocks[currentIdx - 1];
if (!previousBlock) return { groupId, moved: false };
const targetIndex =
previousBlock.groupId === null
? currentBlock.startIndex - 1
: previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex });
}
return { groupId, moved: true };
}
// ── Windows ───────────────────────────────────────────────────────────────────
+560
View File
@@ -0,0 +1,560 @@
// @ts-nocheck
export function contentDispatch(funcName, args) {
function domQuery({ selector }) {
return Array.from(document.querySelectorAll(selector)).map(el => ({
tag: el.tagName.toLowerCase(),
text: el.textContent.trim().slice(0, 200),
attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])),
}));
}
function domClick({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.click();
return true;
}
function domType({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.focus();
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domAttr({ selector, attr }) {
return Array.from(document.querySelectorAll(selector))
.map(el => el.getAttribute(attr))
.filter(v => v !== null);
}
function domText({ selector }) {
return Array.from(document.querySelectorAll(selector))
.map(el => el.textContent.trim())
.filter(Boolean);
}
function domExists({ selector }) {
return document.querySelector(selector) !== null;
}
function domKey({ selector, key }) {
const el = selector ? document.querySelector(selector) : document.activeElement;
if (selector && !el) throw new Error(`No element: ${selector}`);
const target = el || document.body;
["keydown", "keypress", "keyup"].forEach(type => {
target.dispatchEvent(new KeyboardEvent(type, { key, bubbles: true, cancelable: true }));
});
return true;
}
function domHover({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
el.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
return true;
}
function domCheck({ selector, checked }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.checked = checked;
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domClear({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = "";
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
function domFocus({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.focus();
return true;
}
function domSubmit({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
const form = el.tagName === "FORM" ? el : el.closest("form");
if (!form) throw new Error(`No form found for: ${selector}`);
form.submit();
return true;
}
function pageInfo() {
const metas = {};
document.querySelectorAll("meta[name], meta[property]").forEach(m => {
const k = m.getAttribute("name") || m.getAttribute("property");
if (k) metas[k] = m.getAttribute("content") || "";
});
return {
title: document.title,
url: location.href,
readyState: document.readyState,
lang: document.documentElement.lang || null,
meta: metas,
};
}
function domScroll({ selector, x, y }) {
if (selector) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
}
window.scrollTo({ top: y || 0, left: x || 0, behavior: "smooth" });
return true;
}
function domSelect({ selector, value }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function extractLinks() {
const seen = new Set();
return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => {
const href = a.href;
if (!href || seen.has(href)) return links;
seen.add(href);
links.push({
text: a.textContent.trim().slice(0, 100),
href,
});
return links;
}, []);
}
function extractImages() {
const seen = new Set();
return Array.from(document.querySelectorAll("img")).reduce((images, img) => {
const src =
img.src ||
img.getAttribute("data-src") ||
img.getAttribute("data-lazy-src") ||
img.getAttribute("data-original") ||
(img.srcset ? img.srcset.split(",")[0].trim().split(" ")[0] : "") ||
"";
if (!src || seen.has(src)) return images;
seen.add(src);
const FAKE_ALT = new Set(["true", "false", "null", "undefined", "image", "img"]);
const alt = img.alt && !FAKE_ALT.has(img.alt.trim().toLowerCase()) ? img.alt.trim() : "";
images.push({ alt, src });
return images;
}, []);
}
function extractText() {
return document.body.innerText;
}
function extractJson({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`No element: ${selector}`);
return JSON.parse(el.textContent);
}
function extractMarkdown({ selector }) {
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) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeInline(value) {
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) {
return value
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function escapeMarkdown(text) {
return text.replace(/([\\`[\]])/g, "\\$1");
}
function escapeTableCell(text) {
return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function absoluteUrl(attr, fallback) {
return attr || fallback || "";
}
function isNoiseElement(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
const tag = node.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 (node.hasAttribute("hidden")) return true;
if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true;
if (node.matches(".sr-only, [class*='sr-only']")) return true;
if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true;
if (node.matches("[data-testid*='action-button']")) return true;
return false;
}
function stripNoise(root) {
const clone = root.cloneNode(true);
clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove());
return clone;
}
function candidateScore(node) {
const text = normalizeText(node.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.innerText || "").length > 0);
if (!candidates.length) return document.body;
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
return candidates[0];
}
function inlineText(node) {
if (node.nodeType === Node.TEXT_NODE) {
return escapeMarkdown(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const tag = node.tagName.toLowerCase();
if (tag === "br") return "\n";
if (tag === "img") {
const src = absoluteUrl(node.getAttribute("src"), node.src);
if (!src) return "";
const alt = normalizeText(node.getAttribute("alt") || "");
return alt ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`;
}
if (tag === "a") {
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
const href = absoluteUrl(node.getAttribute("href"), node.href);
if (!href) return text;
return `[${text || href}](${href})`;
}
if (tag === "code") {
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
return text ? `\`${text.replace(/`/g, "\\`")}\`` : "";
}
if (tag === "strong" || tag === "b") {
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
return text ? `**${text}**` : "";
}
if (tag === "em" || tag === "i") {
const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join(""));
return text ? `*${text}*` : "";
}
const chunks = [];
for (const child of node.childNodes) {
const rendered = inlineText(child);
if (!rendered) continue;
chunks.push(rendered);
if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) {
chunks.push("\n");
}
}
return chunks.join("");
}
function textBlock(node) {
return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join("")));
}
function preserveNodeText(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const tag = node.tagName.toLowerCase();
if (tag === "br") return "\n";
const parts = [];
for (const child of node.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) {
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) {
const converted = [];
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) {
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) {
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, depth = 0) {
const ordered = list.tagName.toLowerCase() === "ol";
const items = [];
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 = [];
const content = [];
for (const child of item.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) {
nested.push(listToMarkdown(child, 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) {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeText(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const tag = node.tagName.toLowerCase();
if (tag === "table") return tableToMarkdown(node);
if (tag === "ul" || tag === "ol") return listToMarkdown(node);
if (node.matches(".cm-editor[data-is-code-block-view='true']")) {
const lines = Array.from(node.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(node));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "blockquote") {
const content = collapseBlankLines(Array.from(node.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(node);
return text ? `${"#".repeat(level)} ${text}` : "";
}
if (tag === "p" || tag === "figcaption") {
return textBlock(node);
}
if (tag === "hr") {
return "---";
}
if (tag === "img") {
return inlineText(node);
}
const childBlocks = Array.from(node.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);
}
const fns = { domQuery, domClick, domType, domAttr, domText, domExists,
domScroll, domSelect, domKey, domHover, domCheck, domClear, domFocus, domSubmit,
pageInfo,
extractLinks, extractImages, extractText, extractJson, extractMarkdown };
const fn = fns[funcName];
if (!fn) throw new Error(`Unknown content function: ${funcName}`);
return fn(args);
}
// ── Session ───────────────────────────────────────────────────────────────────
+93
View File
@@ -0,0 +1,93 @@
// @ts-nocheck
import { getActiveTab, getAliases, resolveGroupId, tabInfo } from '../core';
export async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
let windowId;
if (explicitWindowId != null) {
windowId = explicitWindowId;
} else if (windowName) {
const aliases = await getAliases();
const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]);
}
const tab = await chrome.tabs.create({ url, active: !background, windowId });
if (groupNameOrId != null) {
let groupId;
try {
groupId = await resolveGroupId(groupNameOrId);
// Close any blank placeholder tabs that were created when the group was made
const groupTabs = await chrome.tabs.query({ groupId });
const placeholders = groupTabs.filter(t =>
t.id !== tab.id &&
(t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/")
);
await chrome.tabs.group({ tabIds: [tab.id], groupId });
if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id));
} catch (e) {
if (!e.message.startsWith("No tab group found")) throw e;
// Group doesn't exist — create it with the tab already in it
groupId = await chrome.tabs.group({ tabIds: [tab.id] });
await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) });
}
}
return { id: tab.id, url: tab.url };
}
export async function navTo({ tabId, url }) {
const tab = await chrome.tabs.update(tabId, { url });
return { id: tab.id, url: tab.url || url };
}
export async function navReload({ tabId }, bypassCache) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache });
return { tabId: tab.id };
}
export async function navBack({ tabId }) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goBack(tab.id);
return { tabId: tab.id };
}
export async function navForward({ tabId }) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.goForward(tab.id);
return { tabId: tab.id };
}
export async function navFocus({ pattern }) {
// If pattern is a plain integer, treat it as a tab ID
const asInt = parseInt(pattern);
let match;
if (!isNaN(asInt) && String(asInt) === String(pattern)) {
match = await chrome.tabs.get(asInt);
} else {
const all = await chrome.tabs.query({});
match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern)));
}
if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: true });
return { id: match.id, url: match.url || match.pendingUrl, title: match.title };
}
export async function navWait({ tabId, timeout = 30000, readyState = "complete" } = {}) {
const tab = tabId ? { id: tabId } : await getActiveTab();
const deadline = Date.now() + timeout;
const interval = 200;
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
if (readyState === "complete" ? t.status === "complete" : t.status !== "loading") {
return tabInfo(t);
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Tab ${tab.id} did not reach status '${readyState}' within ${timeout}ms`);
}
export async function navOpenWait({ url, timeout = 30000, background, window: windowName, group } = {}) {
const opened = await navOpen({ url, background, window: windowName, group });
return await navWait({ tabId: opened.id, timeout });
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
+129
View File
@@ -0,0 +1,129 @@
// @ts-nocheck
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core';
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const groupById = new Map(groups.map(group => [group.id, group]));
const sessionTabs = tabs
.filter(tab => Boolean(tab.url))
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
.map(tab => {
const entry = { url: tab.url };
if (tab.groupId >= 0) {
const group = groupById.get(tab.groupId);
entry.group = {
key: `${tab.windowId}:${tab.groupId}`,
title: group?.title || "",
color: normalizeGroupColor(group?.color),
collapsed: Boolean(group?.collapsed),
};
}
return entry;
});
const sessions = await getSessions();
sessions[name] = {
tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(),
};
await chrome.storage.local.set({ sessions });
return { name, tabs: sessionTabs.length };
}
export async function sessionLoad({ name }) {
const sessions = await getSessions();
const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`);
const sessionTabs = getSessionTabs(session);
const createdTabs = [];
for (const entry of sessionTabs) {
const tab = await chrome.tabs.create({ url: entry.url, active: false });
createdTabs.push({ tabId: tab.id, entry });
}
const groups = new Map();
for (const { tabId, entry } of createdTabs) {
if (!entry.group) continue;
const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`;
if (!groups.has(key)) {
groups.set(key, { meta: entry.group, tabIds: [] });
}
groups.get(key).tabIds.push(tabId);
}
for (const { meta, tabIds } of groups.values()) {
const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, {
title: meta.title || "",
color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed),
});
}
return { name, tabs: sessionTabs.length };
}
export async function sessionList() {
const sessions = await getSessions();
return Object.entries(sessions).map(([name, s]) => ({
name,
tabs: getSessionTabs(s).length,
savedAt: s.savedAt || null,
}));
}
export async function sessionRemove({ name }) {
const sessions = await getSessions();
if (!(name in sessions)) throw new Error(`Session '${name}' not found`);
delete sessions[name];
await chrome.storage.local.set({ sessions });
return { name };
}
export async function sessionDiff({ nameA, nameB }) {
const sessions = await getSessions();
const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url));
const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
return {
added: [...b].filter(u => !a.has(u)),
removed: [...a].filter(u => !b.has(u)),
};
}
export async function sessionAutoSave({ enabled }) {
await chrome.storage.local.set({ autoSave: enabled });
chrome.tabs.onUpdated.removeListener(autoSaveHandler);
chrome.tabs.onRemoved.removeListener(autoSaveHandler);
if (enabled) {
chrome.tabs.onUpdated.addListener(autoSaveHandler);
chrome.tabs.onRemoved.addListener(autoSaveHandler);
}
return { enabled };
}
export async function autoSaveHandler() {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (!autoSave) return;
await sessionSave({ name: "__auto__" });
}
// ── Misc ──────────────────────────────────────────────────────────────────────
export async function clientsList() {
const manifest = chrome.runtime.getManifest();
const alias = await getProfileAlias();
return [{
name: "Chrome",
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform,
extensionVersion: manifest.version,
profile: alias,
}];
}
export async function clientsRenameProfile({ alias }) {
await chrome.storage.local.set({ profileAlias: alias });
return { alias };
}
+214
View File
@@ -0,0 +1,214 @@
// @ts-nocheck
import { executeScript, getActiveTab, getAliases, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core';
export async function tabsList() {
const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases();
const tabs = [];
for (const w of windows) {
for (const t of w.tabs) {
tabs.push({
...tabInfo(t),
windowAlias: aliases[t.windowId] || null,
pinned: t.pinned,
favIconUrl: t.favIconUrl,
});
}
}
return tabs;
}
export async function tabsClose({ tabId, inactive, duplicates }) {
let toClose = [];
if (duplicates) {
const all = await chrome.tabs.query({});
const seen = new Set();
for (const t of all) {
if (!t.url) continue;
if (seen.has(t.url)) toClose.push(t.id);
else seen.add(t.url);
}
} else if (inactive) {
const all = await chrome.tabs.query({});
toClose = all.filter(t => !t.active).map(t => t.id);
} else if (tabId) {
toClose = [tabId];
}
if (toClose.length) await chrome.tabs.remove(toClose);
return { closed: toClose.length };
}
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
const moveProps = {};
if (windowId != null) moveProps.windowId = windowId;
if (forward || backward) {
const tab = await chrome.tabs.get(tabId);
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
else moveProps.index = Math.max(0, tab.index - 1);
} else if (index != null) {
moveProps.index = index;
} else {
moveProps.index = -1;
}
await chrome.tabs.move(tabId, moveProps);
if (groupId != null) {
await chrome.tabs.group({ tabIds: [tabId], groupId });
}
return { tabId };
}
export async function tabsActive({ tabId }) {
const tab = await chrome.tabs.get(tabId);
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(tabId, { active: true });
return { tabId };
}
export async function tabsActiveInWindow({ windowId }) {
const activeTabs = await chrome.tabs.query({ windowId, active: true });
const tab = activeTabs[0];
if (!tab) {
throw new Error(`No active tab found for window ${windowId}`);
}
return tabInfo(tab);
}
export async function tabsStatus({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab);
}
export async function tabsFilter({ pattern }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
}
export async function tabsCount({ pattern }) {
const all = await chrome.tabs.query({});
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
return all.length;
}
export async function tabsQuery({ search }) {
const q = search.toLowerCase();
const all = await chrome.tabs.query({});
return all.filter(t =>
(t.url && t.url.toLowerCase().includes(q)) ||
(t.title && t.title.toLowerCase().includes(q))
).map(tabInfo);
}
export async function tabsHtml({ tabId }) {
for (let i = 0; i < 3; i++) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) {
throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`);
}
try {
const results = await executeScript({
target: { tabId: tab.id },
func: () => document.documentElement.outerHTML,
});
return results[0]?.result || "";
} catch (e) {
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page");
if (i < 2 && transient) {
await new Promise(r => setTimeout(r, 300));
continue;
}
throw e;
}
}
}
export async function tabsDedupe() {
return tabsClose({ duplicates: true });
}
export async function tabsSort({ by }) {
const windows = await chrome.windows.getAll({ populate: true });
let moved = 0;
for (const w of windows) {
const sorted = [...w.tabs].sort((a, b) => {
if (by === "title") return (a.title || "").localeCompare(b.title || "");
if (by === "time") return a.id - b.id; // lower id = opened earlier
// domain (default)
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
return da.localeCompare(db);
});
for (let i = 0; i < sorted.length; i++) {
await chrome.tabs.move(sorted[i].id, { index: i });
moved++;
}
}
return { moved };
}
export async function tabsMergeWindows() {
const current = await chrome.windows.getCurrent();
const all = await chrome.windows.getAll({ populate: true });
let moved = 0;
for (const w of all) {
if (w.id === current.id) continue;
const ids = w.tabs.map(t => t.id);
await chrome.tabs.move(ids, { windowId: current.id, index: -1 });
moved += ids.length;
}
return { moved };
}
export async function tabsPin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: true });
return { tabId: tab.id, pinned: true };
}
export async function tabsUnpin({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
await chrome.tabs.update(tab.id, { pinned: false });
return { tabId: tab.id, pinned: false };
}
export async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
let windowId;
if (tabId) {
const tab = await chrome.tabs.get(tabId);
await chrome.tabs.update(tabId, { active: true });
windowId = tab.windowId;
} else {
const tab = await getActiveTab();
windowId = tab.windowId;
}
const opts = { format };
if (format === "jpeg" && quality != null) opts.quality = quality;
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
return { dataUrl, format };
}
export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const url = t.url || t.pendingUrl || "";
if (regex.test(url)) return tabInfo(t);
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
}
export async function tabsMute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true };
}
export async function tabsUnmute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false };
}
+34
View File
@@ -0,0 +1,34 @@
// @ts-nocheck
import { getAliases } from '../core';
export async function windowsList() {
const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases();
return windows.map(w => ({
id: w.id,
alias: aliases[w.id] || null,
focused: w.focused,
state: w.state,
tabCount: (w.tabs || []).length,
}));
}
export async function windowsRename({ windowId, name }) {
const aliases = await getAliases();
aliases[windowId] = name;
await chrome.storage.local.set({ windowAliases: aliases });
return { windowId, name };
}
export async function windowsClose({ windowId }) {
await chrome.windows.remove(windowId);
return { windowId };
}
export async function windowsOpen({ url }) {
const createData = { focused: true };
if (url) createData.url = url;
const w = await chrome.windows.create(createData);
return { id: w.id };
}
// ── DOM / Extract ─────────────────────────────────────────────────────────────
+131
View File
@@ -0,0 +1,131 @@
// @ts-nocheck
// Shared helpers for browser-cli extension command handlers.
export async function getProfileAlias() {
const { profileAlias } = await chrome.storage.local.get("profileAlias");
return profileAlias || "default";
}
export async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await chrome.scripting.executeScript(options);
} catch (e) {
if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) {
await new Promise(r => setTimeout(r, 300));
continue;
}
throw e;
}
}
}
export function tabInfo(t) {
return {
id: t.id,
windowId: t.windowId,
active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
title: t.title,
url: t.url,
};
}
// ── Groups ────────────────────────────────────────────────────────────────────
export function isScriptableUrl(url) {
if (!url) return false;
return !url.startsWith("chrome://") &&
!url.startsWith("brave://") &&
!url.startsWith("about:") &&
!url.startsWith("edge://") &&
!url.startsWith("chrome-extension://");
}
export async function getActiveTab() {
const activeTabs = await chrome.tabs.query({ active: true });
if (!activeTabs.length) throw new Error("No active tab found");
const windows = await chrome.windows.getAll({ populate: false });
const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id));
const chooseTab = (predicate) => activeTabs.find(predicate);
const byFocusAndScriptable = tab => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || "");
const byScriptable = tab => isScriptableUrl(tab.url || tab.pendingUrl || "");
const byFocus = tab => focusedWindowIds.has(tab.windowId);
return chooseTab(byFocusAndScriptable)
|| chooseTab(byScriptable)
|| chooseTab(byFocus)
|| activeTabs[0];
}
export async function resolveTabForDirectAction(tabId, actionName) {
if (tabId != null) {
return chrome.tabs.get(tabId);
}
const allTabs = await chrome.tabs.query({});
if (allTabs.length !== 1) {
throw new Error(
`Refusing to ${actionName} without explicit tab ID when ${allTabs.length} tabs are open`
);
}
return allTabs[0];
}
export async function resolveGroupId(nameOrId) {
const asInt = parseInt(nameOrId);
if (!isNaN(asInt)) return asInt;
const groups = await chrome.tabGroups.query({});
const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase());
if (!match) throw new Error(`No tab group found with name '${nameOrId}'`);
return match.id;
}
export function buildTabBlocks(tabs) {
const blocks = [];
for (const tab of tabs) {
const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null;
const lastBlock = blocks[blocks.length - 1];
if (lastBlock?.groupId === normalizedGroupId) {
lastBlock.tabIds.push(tab.id);
lastBlock.endIndex = tab.index;
continue;
}
blocks.push({
groupId: normalizedGroupId,
startIndex: tab.index,
endIndex: tab.index,
tabIds: [tab.id],
});
}
return blocks;
}
export function getSessionTabs(session) {
if (!session) return [];
if (Array.isArray(session.tabs)) {
return session.tabs
.map(entry => typeof entry === "string" ? { url: entry } : entry)
.filter(entry => entry?.url);
}
if (Array.isArray(session.urls)) {
return session.urls.filter(Boolean).map(url => ({ url }));
}
return [];
}
export function normalizeGroupColor(color) {
const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
return allowed.has(color) ? color : "grey";
}
export async function getAliases() {
const { windowAliases } = await chrome.storage.local.get("windowAliases");
return windowAliases || {};
}
export async function getSessions() {
const { sessions } = await chrome.storage.local.get("sessions");
return sessions || {};
}
+246
View File
@@ -0,0 +1,246 @@
// @ts-nocheck
/**
* browser-cli Extension Background Service Worker
*
* Connects to the native host (com.browsercli.host) via Native Messaging.
*/
import { getProfileAlias } from './core';
import * as nav from './commands/navigation';
import * as tabs from './commands/tabs';
import * as groups from './commands/groups';
import * as windowsCmd from './commands/windows';
import * as dom from './commands/dom';
import * as browserData from './commands/browser-data';
import * as session from './commands/session';
const NATIVE_HOST = "com.browsercli.host";
let port = null;
let keepaliveEnabled = true;
// ── Connection management ─────────────────────────────────────────────────────
function sendControlMessage(targetPort, message) {
if (!targetPort) return;
try {
targetPort.postMessage(message);
} catch (e) {
console.warn("[browser-cli] Failed to send control message:", e);
}
}
function disconnectPort({ sendBye = false } = {}) {
const currentPort = port;
if (!currentPort) return;
if (sendBye) sendControlMessage(currentPort, { type: "bye" });
if (port === currentPort) port = null;
try {
currentPort.disconnect();
} catch (e) {
console.warn("[browser-cli] Failed to disconnect native port:", e);
}
}
async function connect() {
if (port || !keepaliveEnabled) return;
try {
const nativePort = chrome.runtime.connectNative(NATIVE_HOST);
port = nativePort;
nativePort.onMessage.addListener(onMessage);
nativePort.onDisconnect.addListener(() => {
if (port === nativePort) port = null;
const err = chrome.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message);
});
// Send hello so native host knows which profile/alias this is
const alias = await getProfileAlias();
nativePort.postMessage({ type: "hello", alias });
console.log("[browser-cli] Connected to native host as profile:", alias);
} catch (e) {
port = null;
console.error("[browser-cli] Failed to connect:", e);
}
}
chrome.runtime.onInstalled.addListener(connect);
chrome.runtime.onStartup.addListener(connect);
chrome.runtime.onSuspend.addListener(() => {
disconnectPort({ sendBye: true });
});
chrome.windows.onCreated.addListener(() => {
keepaliveEnabled = true;
if (!port) connect();
});
chrome.windows.onRemoved.addListener(async () => {
const windows = await chrome.windows.getAll({});
if (windows.length > 0) return;
keepaliveEnabled = false;
disconnectPort({ sendBye: true });
});
// Keepalive alarm — prevents service worker suspension and reconnects if needed
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepalive") {
if (!port && keepaliveEnabled) connect();
}
});
// ── Message dispatcher ────────────────────────────────────────────────────────
async function onMessage(msg) {
const { id, command, args } = msg;
if (!id || !command) return;
console.log("[browser-cli] ←", command, args);
let data, error;
try {
const { __page, ...commandArgs } = args || {};
data = await dispatch(command, commandArgs);
if (__page && Array.isArray(data)) {
data = makePagedData(data, __page);
}
} catch (e) {
error = e.message || String(e);
}
if (error !== undefined) {
console.log("[browser-cli] → ERROR", command, error);
port.postMessage({ id, success: false, error });
} else {
console.log("[browser-cli] →", command, data);
port.postMessage({ id, success: true, data });
}
if (command === "clients.rename_profile" && error === undefined) {
disconnectPort({ sendBye: true });
keepaliveEnabled = true;
await connect();
}
}
function makePagedData(items, page) {
const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0);
const requestedLimit = Math.max(1, Number(page.limit) || 100);
const limit = Math.min(requestedLimit, 1000);
const end = Math.min(offset + limit, total);
return {
__browserCliPage: true,
items: items.slice(offset, end),
offset,
limit,
total,
nextOffset: end < total ? end : null,
};
}
async function dispatch(command, args) {
switch (command) {
// ── Navigation ────────────────────────────────────────────────────────
case "navigate.open": return nav.navOpen(args);
case "navigate.to": return nav.navTo(args);
case "navigate.reload": return nav.navReload(args, false);
case "navigate.hard_reload": return nav.navReload(args, true);
case "navigate.back": return nav.navBack(args);
case "navigate.forward": return nav.navForward(args);
case "navigate.focus": return nav.navFocus(args);
case "navigate.wait": return nav.navWait(args);
case "navigate.open_wait": return nav.navOpenWait(args);
// ── Tabs ──────────────────────────────────────────────────────────────
case "tabs.list": return tabs.tabsList();
case "tabs.close": return tabs.tabsClose(args);
case "tabs.move": return tabs.tabsMove(args);
case "tabs.active": return tabs.tabsActive(args);
case "tabs.active_in_window": return tabs.tabsActiveInWindow(args);
case "tabs.status": return tabs.tabsStatus(args);
case "tabs.filter": return tabs.tabsFilter(args);
case "tabs.count": return tabs.tabsCount(args);
case "tabs.query": return tabs.tabsQuery(args);
case "tabs.html": return tabs.tabsHtml(args);
case "tabs.dedupe": return tabs.tabsDedupe();
case "tabs.sort": return tabs.tabsSort(args);
case "tabs.merge_windows": return tabs.tabsMergeWindows();
case "tabs.mute": return tabs.tabsMute(args);
case "tabs.unmute": return tabs.tabsUnmute(args);
case "tabs.pin": return tabs.tabsPin(args);
case "tabs.unpin": return tabs.tabsUnpin(args);
case "tabs.screenshot": return tabs.tabsScreenshot(args);
case "tabs.watch_url": return tabs.tabsWatchUrl(args);
// ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groups.groupList();
case "group.tabs": return groups.groupTabs(args);
case "group.count": return groups.groupCount();
case "group.query": return groups.groupQuery(args);
case "group.close": return groups.groupClose(args);
case "group.open": return groups.groupOpen(args);
case "group.add_tab": return groups.groupAddTab(args);
case "group.move": return groups.groupMove(args);
// ── Windows ───────────────────────────────────────────────────────────
case "windows.list": return windowsCmd.windowsList();
case "windows.rename": return windowsCmd.windowsRename(args);
case "windows.close": return windowsCmd.windowsClose(args);
case "windows.open": return windowsCmd.windowsOpen(args);
// ── DOM ───────────────────────────────────────────────────────────────
case "dom.query": return dom.domOp("domQuery", args);
case "dom.click": return dom.domOp("domClick", args);
case "dom.type": return dom.domOp("domType", args);
case "dom.attr": return dom.domOp("domAttr", args);
case "dom.text": return dom.domOp("domText", args);
case "dom.exists": return dom.domOp("domExists", args);
case "dom.scroll": return dom.domOp("domScroll", args);
case "dom.select": return dom.domOp("domSelect", args);
case "dom.key": return dom.domOp("domKey", args);
case "dom.hover": return dom.domOp("domHover", args);
case "dom.check": return dom.domOp("domCheck", { ...args, checked: true });
case "dom.uncheck": return dom.domOp("domCheck", { ...args, checked: false });
case "dom.clear": return dom.domOp("domClear", args);
case "dom.focus": return dom.domOp("domFocus", args);
case "dom.submit": return dom.domOp("domSubmit", args);
case "dom.eval": return dom.domEval(args);
case "dom.wait_for": return dom.domWaitFor(args);
case "dom.poll": return dom.domPoll(args);
// ── Page ──────────────────────────────────────────────────────────────
case "page.info": return dom.domOp("pageInfo", {});
// ── Storage ───────────────────────────────────────────────────────────
case "storage.get": return browserData.storageGet(args);
case "storage.set": return browserData.storageSet(args);
// ── Cookies ───────────────────────────────────────────────────────────
case "cookies.list": return browserData.cookiesList(args);
case "cookies.get": return browserData.cookiesGet(args);
case "cookies.set": return browserData.cookiesSet(args);
// ── Extract ───────────────────────────────────────────────────────────
case "extract.links": return dom.domOp("extractLinks", args);
case "extract.images": return dom.domOp("extractImages", args);
case "extract.text": return dom.domOp("extractText", args);
case "extract.json": return dom.domOp("extractJson", args);
case "extract.markdown": return dom.domOp("extractMarkdown", args);
case "extract.html": return tabs.tabsHtml({});
// ── Session ───────────────────────────────────────────────────────────
case "session.save": return session.sessionSave(args);
case "session.load": return session.sessionLoad(args);
case "session.list": return session.sessionList();
case "session.remove": return session.sessionRemove(args);
case "session.diff": return session.sessionDiff(args);
case "session.auto_save": return session.sessionAutoSave(args);
// ── Misc ──────────────────────────────────────────────────────────────
case "clients.list": return session.clientsList();
case "clients.rename_profile": return session.clientsRenameProfile(args);
default:
throw new Error(`Unknown command: ${command}`);
}
}
// ── Navigation ────────────────────────────────────────────────────────────────
+548
View File
@@ -0,0 +1,548 @@
{
"name": "browser-cli-extension-build",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browser-cli-extension-build",
"devDependencies": {
"@types/chrome": "^0.1.40",
"esbuild": "^0.28.0",
"typescript": "^6.0.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/chrome": {
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz",
"integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "browser-cli-extension-build",
"private": true,
"type": "module",
"scripts": {
"build:extension": "esbuild extension/src/index.ts --bundle --format=iife --target=chrome120 --outfile=extension/background.js",
"check:extension": "tsc -p tsconfig.json --noEmit && npm run build:extension && node --check extension/background.js"
},
"devDependencies": {
"@types/chrome": "^0.1.40",
"esbuild": "^0.28.0",
"typescript": "^6.0.3"
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
[project]
name = "browser-cli"
version = "0.7.0"
version = "0.9.2"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
"click>=8",
"cryptography>=42",
"rich>=13",
]
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env -S uv run
"""Generate or derive Chrome extension key and ID.
Usage:
python scripts/gen_extension_key.py # generate new key pair
python scripts/gen_extension_key.py --from-manifest # derive ID from extension/manifest.json
python scripts/gen_extension_key.py --key <base64> # derive ID from given public key
"""
import argparse, hashlib
import base64, json, sys
from pathlib import Path
def public_key_to_extension_id(pub_key_der:bytes) -> str:
digest = hashlib.sha256(pub_key_der).hexdigest()
return "".join(chr(ord("a") + int(c, 16)) for c in digest[:32])
def derive_from_key_b64(key_b64:str) -> tuple[str, str]:
der = base64.b64decode(key_b64)
ext_id = public_key_to_extension_id(der)
return key_b64, ext_id
def generate_new_key() -> tuple[str, str, str]:
try:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
PrivateFormat,
NoEncryption,
)
except ImportError:
print("Install 'cryptography' to generate new keys: pip install cryptography", file=sys.stderr)
sys.exit(1)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_der = private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
priv_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()).decode()
key_b64 = base64.b64encode(pub_der).decode()
ext_id = public_key_to_extension_id(pub_der)
return key_b64, ext_id, priv_pem
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Chrome extension key/ID tool")
group = parser.add_mutually_exclusive_group()
group.add_argument("--from-manifest", action="store_true", help="Derive ID from extension/manifest.json")
group.add_argument("--key", metavar="BASE64", help="Derive ID from given base64 public key")
args = parser.parse_args()
if args.from_manifest:
manifest_path = Path(__file__).parent.parent / "extension" / "manifest.json"
manifest = json.loads(manifest_path.read_text())
key_b64 = manifest.get("key")
if not key_b64:
print("No 'key' field in manifest.json", file=sys.stderr)
sys.exit(1)
key_b64, ext_id = derive_from_key_b64(key_b64)
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
elif args.key:
key_b64, ext_id = derive_from_key_b64(args.key)
print(f"Extension ID: {ext_id}")
else:
key_b64, ext_id, priv_pem = generate_new_key()
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
print()
print("Add this to extension/manifest.json:")
print(f' "key": "{key_b64}"')
print()
print("Private key (keep secret, needed to re-derive same ID):")
print(priv_pem)
+20
View File
@@ -0,0 +1,20 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
packages = with pkgs; [
nodejs_22
uv
python3
];
shellHook = ''
echo "browser-cli dev shell: node $(node --version), npm $(npm --version), uv $(uv --version | awk '{print $2}')"
if [ -f package-lock.json ]; then
if [ ! -f node_modules/.package-lock.json ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then
echo "Installing extension dependencies with npm ci..."
npm ci
fi
fi
'';
}
+2 -2
View File
@@ -17,9 +17,9 @@ def browser():
"""Returns a connected send_command callable for the testing profile, or skips the test."""
try:
send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
except BrowserNotConnected:
except (BrowserNotConnected, RuntimeError) as e:
pytest.skip(
"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile"
f"Browser 'testing' not connected — start Brave/Chrome with the extension loaded for that profile ({e})"
)
def _browser(command, args=None):
+84 -36
View File
@@ -5,6 +5,7 @@ These tests mock `send_command` so no live browser connection is required.
import pytest
from unittest.mock import MagicMock, patch, call
import browser_cli
from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
from browser_cli.client import BrowserNotConnected, BrowserTarget
@@ -63,6 +64,11 @@ class TestBrowserCLIInit:
b = BrowserCLI(browser="chrome")
assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", key=None)
assert b._browser == "work"
assert b._remote == "host:8765"
# ── Internal factories ────────────────────────────────────────────────────────
@@ -122,7 +128,7 @@ class TestNavigation:
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": False, "window": None, "group": None},
profile=None,
profile=None, remote=None, key=None,
)
def test_open_background(self, b, mock_send):
@@ -136,33 +142,38 @@ class TestNavigation:
def test_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
def test_hard_reload(self, b, mock_send):
b.hard_reload(tab_id=7)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
def test_back(self, b, mock_send):
b.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
def test_forward(self, b, mock_send):
b.forward(tab_id=3)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
def test_focus_url(self, b, mock_send):
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", key=None)
b.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
# ── Search ────────────────────────────────────────────────────────────────────
@@ -195,12 +206,12 @@ class TestExtract:
result = b.extract_markdown()
assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
def test_extract_markdown_selector(self, b, mock_send):
b.extract_markdown("article")
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
# ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -235,7 +246,7 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": 10, "inactive": False, "duplicates": False},
profile=None,
profile=None, remote=None, key=None,
)
def test_tabs_move(self, b, mock_send):
@@ -243,19 +254,19 @@ class TestTabs:
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None,
profile=None, remote=None, key=None,
)
def test_tabs_active(self, b, mock_send):
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None)
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
@@ -274,7 +285,6 @@ class TestTabs:
def test_tabs_filter_predicate(self, b, mock_send):
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}]
tabs = b.tabs_filter(lambda tab: "youtube" in tab.url)
print(tabs)
assert [tab.id for tab in tabs] == [11]
def test_tabs_filter_list_transformer(self, b, mock_send):
@@ -308,7 +318,26 @@ class TestTabs:
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs_list()
tabs[0].close()
assert [tab.browser for tab in tabs] == ["host:work"]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -351,7 +380,7 @@ class TestTabs:
def test_tabs_sort(self, b, mock_send):
b.tabs_sort(by="title")
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None)
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, key=None)
def test_tabs_merge_windows(self, b, mock_send):
mock_send.return_value = {"moved": 4}
@@ -384,7 +413,7 @@ class TestGroups:
mock_send.return_value = [TAB_DATA]
tabs = b.group_tabs(42)
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_count(self, b, mock_send):
mock_send.return_value = 7
@@ -412,7 +441,26 @@ class TestGroups:
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work"),
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", key=None)
with patch(
"browser_cli.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.group_list()
groups[0].close()
assert [group.browser for group in groups] == ["host:work"]
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
@@ -435,7 +483,7 @@ class TestGroups:
def test_group_close(self, b, mock_send):
b.group_close(42)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_create_dict_response(self, b, mock_send):
mock_send.return_value = GROUP_DATA
@@ -455,7 +503,7 @@ class TestGroups:
tab_id = b.group_add_tab(42, "https://example.com")
assert tab_id == 55
mock_send.assert_called_once_with(
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_group_add_tab_non_dict_response(self, b, mock_send):
@@ -465,7 +513,7 @@ class TestGroups:
def test_group_move_forward(self, b, mock_send):
b.group_move(42, forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
@@ -495,7 +543,7 @@ class TestWindows:
result = b.windows_open()
assert result == {"id": 5}
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None)
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
def test_windows_open_with_url(self, b, mock_send):
mock_send.return_value = {"id": 9}
@@ -503,7 +551,7 @@ class TestWindows:
result = b.windows_open("https://example.com")
assert result == {"id": 9}
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestSession:
@@ -513,7 +561,7 @@ class TestSession:
result = b.session_list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None)
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
@@ -548,26 +596,26 @@ class TestTabModel:
def test_close(self, tab, mock_send):
tab.close()
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_hard_reload(self, tab, mock_send):
tab.hard_reload()
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_move_forward(self, tab, mock_send):
tab.move(forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None,
profile=None, remote=None, key=None,
)
def test_move_to_group(self, tab, mock_send):
@@ -577,12 +625,12 @@ class TestTabModel:
def test_html(self, tab, mock_send):
mock_send.return_value = "<html/>"
assert tab.html() == "<html/>"
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None)
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
@@ -590,7 +638,7 @@ class TestTabModel:
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None,
profile=None, remote=None, key=None,
)
def test_unbound_raises(self):
@@ -608,18 +656,18 @@ class TestGroupModel:
def test_close(self, group, mock_send):
group.close()
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = group.tabs()
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_move_forward(self, group, mock_send):
group.move(forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
def test_move_backward(self, group, mock_send):
+158 -34
View File
@@ -1,5 +1,6 @@
from pathlib import Path
from types import SimpleNamespace
import os
import sys
from click.testing import CliRunner
@@ -79,22 +80,15 @@ def test_install_help_lists_supported_browsers():
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
def test_install_windows_registers_native_host(tmp_path):
writes = []
class FakeKey:
def __init__(self, path):
self.path = path
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
def __exit__(self, _exc_type, _exc, _tb):
return False
fake_winreg = SimpleNamespace(
@@ -103,43 +97,135 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100,
REG_SZ=1,
CreateKeyEx=lambda _root, path, _reserved, _access: FakeKey(path),
SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append((key.path, name, value)),
)
def fake_create_key(root, path, reserved, access):
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
host_exe = tmp_path / "browser-cli-native-host.exe"
with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
"browser_cli.cli.shutil.copy2"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
"browser_cli.cli._native_host_exe", return_value=host_exe
), patch("browser_cli.cli._write_native_host_exe"), patch(
"browser_cli.cli.Path.write_text"
), patch.dict(sys.modules, {"winreg": fake_winreg}):
result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
assert any("@echo off" in text for text in wrapper_writes)
def test_write_native_host_exe_unix(tmp_path):
from browser_cli.cli import _write_native_host_exe
host = tmp_path / "libexec" / "browser-cli-native-host"
with patch("browser_cli.cli.is_windows", return_value=False):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text()
assert content.startswith(f"#!{sys.executable}")
assert "from browser_cli.native_host import main" in content
assert host.stat().st_mode & 0o111 # executable bit set
def test_write_native_host_exe_windows(tmp_path):
from browser_cli.cli import _write_native_host_exe
host = tmp_path / "libexec" / "browser-cli-native-host.cmd"
with patch("browser_cli.cli.is_windows", return_value=True):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text(encoding="utf-8")
assert "@echo off" in content
assert "browser_cli.native_host" in content
def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.active_browser_targets", return_value=[]
):
result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 1
assert "No browser clients found" in result.output
def test_clients_reads_registry_with_trailing_garbage(tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
def fake_send_command(command, args=None, profile=None):
assert command == "clients.list"
assert profile == "main"
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
"browser_cli.cli.send_command", side_effect=fake_send_command
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0
assert "main" in result.output
assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile is None
assert remote == "127.0.0.1:8765"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
assert "remote" in result.output
assert "Chrome" in result.output
assert "2.3.4" in result.output
def test_clients_remote_respects_global_browser_route():
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.cli.send_command", return_value=[]) as send_command:
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
def test_clients_browser_alias_resolves_to_remote():
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
and shows only clients from that host (not local profiles)."""
from browser_cli.client import BrowserTarget
resolved_target = BrowserTarget(
profile="automatisation",
display_name="192.168.188.104:automatisation",
socket_path="",
remote="192.168.188.104:8765",
)
all_remote_targets = [resolved_target]
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "clients.list"
assert profile == "automatisation"
assert remote == "192.168.188.104:8765"
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.cli.remote_target_for_alias", return_value=resolved_target
), patch(
"browser_cli.cli.remote_browser_targets", return_value=all_remote_targets
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
result = CliRunner().invoke(main, ["--browser", "192.168.188.104", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
assert "Chrome" in result.output
assert "0.8.5" in result.output
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
registry_path = tmp_path / "registry.json"
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
@@ -160,7 +246,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
"browser_cli.cli.send_command", side_effect=fake_send_command
):
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0
@@ -190,6 +276,25 @@ def test_tabs_list_multi_browser_shows_browser_column():
assert "work" in result.output
def test_tabs_list_with_remote_uses_only_remote_targets():
with patch(
"browser_cli.commands.tabs.active_browser_targets",
side_effect=AssertionError("local targets should not be used for explicit remote"),
), patch(
"browser_cli.commands.tabs.remote_browser_targets",
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
assert result.exit_code == 0
assert "remote-host:work" in result.output
assert "Remote" in result.output
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
with patch(
"browser_cli.commands.tabs.active_browser_targets",
@@ -288,7 +393,6 @@ def test_groups_move_accepts_left_short_alias():
)
def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "windows.list"
@@ -524,3 +628,23 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
"- Unternehmensdaten → RAG → KI-Orchestrierung →\n"
" Local LLMs / API Modelle / Spezialmodelle"
) in markdown
def test_tabs_list_multi_browser_queries_remote_target():
endpoint = "browser-host.example:8765"
remote_target = BrowserTarget(
"work",
"browser-host.example:work",
"",
remote=endpoint,
)
with patch("browser_cli.commands.tabs.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command:
result = CliRunner().invoke(main, ["tabs", "list"])
assert result.exit_code == 0
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
assert "browser-host.example:work" in result.output
+200 -3
View File
@@ -3,7 +3,16 @@ from pathlib import Path
import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
from browser_cli.client import (
BrowserNotConnected,
BrowserTarget,
_resolve_socket,
active_browser_targets,
display_browser_name,
key_for_remote,
send_command,
remote_target_for_alias,
)
from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
@@ -63,7 +72,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
targets = active_browser_targets()
targets = active_browser_targets(include_remotes=False)
assert len(targets) == 1
assert targets[0].profile == "work"
@@ -77,7 +86,195 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
targets = active_browser_targets()
targets = active_browser_targets(include_remotes=False)
assert len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
sent = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
sent.update(msg)
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
assert sent["_route"] == "work"
assert "token" not in sent
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list") == []
assert sent["endpoint"] == "host:8765"
assert sent["_route"] == "work"
assert "token" not in sent
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
)
target = remote_target_for_alias("host:8765:work")
assert target is not None
assert target.profile == "work"
assert target.remote == "host:8765"
def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkeypatch):
remote_host = "browser-host.example"
remote_endpoint = f"{remote_host}:8765"
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
target = remote_target_for_alias(remote_host)
assert target is not None
assert target.profile == "work"
assert target.remote == remote_endpoint
def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
remote_host = "browser-host.example"
remote_endpoint = f"{remote_host}:8765"
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
sent = {}
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
sent["endpoint"] = endpoint
sent.update(msg)
return json.dumps({"success": True, "data": []}).encode("utf-8")
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
assert send_command("tabs.list") == []
assert sent["endpoint"] == remote_endpoint
assert sent["_route"] == "work"
assert "token" not in sent
def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch):
monkeypatch.setattr(
"browser_cli.client._remote_browser_targets",
lambda: [
BrowserTarget("main", "host:main", "", remote="host:8765"),
BrowserTarget("work", "host:work", "", remote="host:8765"),
],
)
assert remote_target_for_alias("host") is None
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [
BrowserTarget("main", "host:main", "", remote=endpoint),
BrowserTarget("furry", "host:furry", "", remote=endpoint),
],
)
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
send_command("tabs.list", remote="host:8765")
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
endpoint = "browser-host.example:8765"
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
assert command == "browser-cli.targets"
assert remote == endpoint
return [{"profile": "work", "displayName": "work"}]
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].profile == "work"
assert targets[0].display_name == "browser-host.example:work"
assert targets[0].remote == endpoint
def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path):
"""--key agent is saved on first use; omitting --key on subsequent calls reuses it."""
import json as _json
remotes_path = tmp_path / "remotes.json"
remotes_path.write_text("{}", encoding="utf-8")
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
from pathlib import Path as _Path
used_keys = []
def fake_load_private_key(key_path=None):
used_keys.append(str(key_path) if key_path is not None else None)
return None # no actual key needed for this test
monkeypatch.setattr("browser_cli.client._load_private_key", fake_load_private_key)
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
)
def fake_send_remote(endpoint, msg, private_key=None):
return _json.dumps({"success": True, "data": "ok"}).encode()
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
# First call with explicit --key agent
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
assert used_keys[-1] == "agent"
# Key must be persisted now
assert key_for_remote("host:8765") == "agent"
# Second call without --key — should reuse saved "agent"
send_command("tabs.list", remote="host:8765")
assert used_keys[-1] == "agent"
-2
View File
@@ -1,6 +1,4 @@
"""Tests for dom.* commands (require an http/https active tab)."""
import pytest
from browser_cli.client import send_command
def test_dom_query_body(browser, http_tab):
-14
View File
@@ -1,6 +1,4 @@
"""Tests for extract.* commands (require an http/https active tab)."""
import pytest
from browser_cli.client import send_command
def test_extract_links(browser, http_tab):
@@ -58,15 +56,3 @@ def test_extract_markdown_missing_selector_errors(browser, http_tab):
assert "No element" in str(exc)
def test_dom_exists(browser, http_tab):
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.exists", {"selector": "body"})
assert result is True
def test_dom_query(browser, http_tab):
browser("tabs.active", {"tabId": http_tab["id"]})
elements = browser("dom.query", {"selector": "body"})
assert isinstance(elements, list)
assert len(elements) > 0
assert elements[0].get("tag") == "body"
-1
View File
@@ -1,6 +1,5 @@
"""Tests for group.* commands."""
import pytest
from browser_cli.client import send_command
def test_group_list(browser):
+44
View File
@@ -84,3 +84,47 @@ def test_stdin_reader_routes_response_messages(monkeypatch):
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
native_host.PENDING.clear()
def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
calls = []
pages = iter([
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
])
def fake_send(cmd):
calls.append(cmd)
return next(pages)
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
assert [call["args"]["__page"] for call in calls] == [
{"offset": 0, "limit": 2},
{"offset": 2, "limit": 2},
]
assert all(call["args"]["foo"] == "bar" for call in calls)
assert all(call["id"] != "orig" for call in calls)
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
def test_handle_browser_command_pages_known_list_commands(monkeypatch):
seen = []
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
assert result == {"success": True, "data": []}
assert seen[0]["command"] == "tabs.list"
-2
View File
@@ -1,6 +1,4 @@
"""Tests for navigate.* commands."""
import pytest
from browser_cli.client import send_command
def test_nav_open_and_close(browser):
+30
View File
@@ -0,0 +1,30 @@
import json
from browser_cli.registry import load_registry, save_registry, update_registry
def test_load_registry_tolerates_trailing_garbage_from_old_non_atomic_writes(tmp_path):
registry = tmp_path / "registry.json"
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
assert load_registry(registry) == {"main": "/tmp/.browser_cli/main.sock"}
def test_update_registry_repairs_corrupted_registry_and_preserves_entries(tmp_path):
registry = tmp_path / "registry.json"
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
update_registry("work", "/tmp/.browser_cli/work.sock", registry)
assert json.loads(registry.read_text(encoding="utf-8")) == {
"main": "/tmp/.browser_cli/main.sock",
"work": "/tmp/.browser_cli/work.sock",
}
def test_save_registry_writes_valid_json_atomically(tmp_path):
registry = tmp_path / "registry.json"
save_registry({"main": "/tmp/main.sock"}, registry)
assert json.loads(registry.read_text(encoding="utf-8")) == {"main": "/tmp/main.sock"}
-1
View File
@@ -1,6 +1,5 @@
"""Tests for session.* commands."""
import time
import pytest
from browser_cli.client import send_command
from tests.conftest import TEST_BROWSER_PROFILE
-1
View File
@@ -1,6 +1,5 @@
"""Tests for tabs.* commands."""
import pytest
from browser_cli.client import send_command
def test_tabs_list(browser):
-2
View File
@@ -1,6 +1,4 @@
"""Tests for windows.* commands."""
import pytest
from browser_cli.client import send_command
def test_windows_list(browser):
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"types": ["chrome"],
"allowJs": false,
"strict": false,
"noEmit": true,
"skipLibCheck": true
},
"include": ["extension/src/**/*.ts"]
}
Generated
+163 -10
View File
@@ -4,10 +4,11 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.7.0"
version = "0.9.2"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "cryptography" },
{ name = "rich" },
]
@@ -19,22 +20,105 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8" },
{ name = "cryptography", specifier = ">=42" },
{ name = "rich", specifier = ">=13" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.2"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
@@ -46,6 +130,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "47.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" },
{ url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" },
{ url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" },
{ url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -90,11 +234,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.0"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
@@ -106,6 +250,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -135,15 +288,15 @@ wheels = [
[[package]]
name = "rich"
version = "14.3.3"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]