Compare commits

..

14 Commits

Author SHA1 Message Date
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
45 changed files with 3260 additions and 1820 deletions
+17 -2
View File
@@ -14,6 +14,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Read extension version
id: version id: version
run: | run: |
@@ -29,8 +41,11 @@ jobs:
- name: Build extension archive - name: Build extension archive
run: | run: |
mkdir -p dist rm -rf extension-package
cd extension 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" . zip -r "../dist/browser-cli-extension-v${{ steps.version.outputs.version }}.zip" .
- name: Publish extension release asset - name: Publish extension release asset
+9 -2
View File
@@ -1,4 +1,11 @@
__pycache__/ # TypeScript / Node
.vscode/ extension/background.js
node_modules/
dist/
# Python
__pycache__/
*.pyc *.pyc
# IDE
.vscode/
+22 -2
View File
@@ -95,8 +95,9 @@ browser-cli/
│ └── session.py # session save/load │ └── session.py # session save/load
├── extension/ ├── extension/
│ ├── manifest.json # MV3 extension manifest │ ├── 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/ ├── examples/
│ ├── demo.py # Python API walkthrough │ ├── demo.py # Python API walkthrough
│ └── demo.sh # Bash CLI 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 ## Limitations
- **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **Browser internal pages** (`chrome://`, `brave://`, `edge://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages.
+52 -9
View File
@@ -19,7 +19,7 @@ Usage:
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import dataclass 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 from browser_cli.models import Group, Tab
__all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"] __all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"]
@@ -40,7 +40,8 @@ class BrowserCLI:
instances are active. Equivalent to ``--browser`` on the CLI. instances are active. Equivalent to ``--browser`` on the CLI.
remote: Connect to a remote browser exposed via ``browser-cli serve``. remote: Connect to a remote browser exposed via ``browser-cli serve``.
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``). Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
When set, ``browser`` is ignored. Can be combined with ``browser`` to route to a specific
remote profile.
token: Auth token for the remote serve instance. token: Auth token for the remote serve instance.
""" """
self._browser = browser self._browser = browser
@@ -53,8 +54,11 @@ class BrowserCLI:
def _multi_browser_targets(self): def _multi_browser_targets(self):
if self._browser is not None: if self._browser is not None:
return [] return []
if self._remote:
targets = remote_browser_targets(self._remote, self._token)
else:
targets = active_browser_targets() targets = active_browser_targets()
if len(targets) <= 1: if len(targets) <= 1 and not any(target.remote for target in targets):
return [] return []
return targets return targets
@@ -62,6 +66,9 @@ class BrowserCLI:
results = [] results = []
for target in self._multi_browser_targets(): for target in self._multi_browser_targets():
try: try:
if target.remote:
data = send_command(command, args, profile=target.profile, remote=target.remote, token=target.token)
else:
data = send_command(command, args, profile=target.profile) data = send_command(command, args, profile=target.profile)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
continue continue
@@ -78,7 +85,15 @@ class BrowserCLI:
# ── Internal factories ──────────────────────────────────────────────── # ── 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,
browser_token: str | None = None,
) -> Tab:
tab = Tab( tab = Tab(
id=data["id"], id=data["id"],
window_id=data.get("windowId", 0), window_id=data.get("windowId", 0),
@@ -89,10 +104,22 @@ class BrowserCLI:
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
browser=browser_name, 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,
token=browser_token,
)
return tab 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,
browser_token: str | None = None,
) -> Group:
group = Group( group = Group(
id=data["id"], id=data["id"],
title=data.get("title") or "", title=data.get("title") or "",
@@ -101,7 +128,11 @@ class BrowserCLI:
tab_count=data.get("tabCount", 0), tab_count=data.get("tabCount", 0),
browser=browser_name, 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,
token=browser_token,
)
return group return group
# ── Navigation ──────────────────────────────────────────────────────── # ── Navigation ────────────────────────────────────────────────────────
@@ -193,7 +224,13 @@ class BrowserCLI:
multi_results = self._collect_multi_browser("tabs.list", {}) multi_results = self._collect_multi_browser("tabs.list", {})
if multi_results: if multi_results:
return [ 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,
browser_token=target.token,
)
for target, tabs in multi_results for target, tabs in multi_results
for tab in (tabs or []) for tab in (tabs or [])
] ]
@@ -340,7 +377,13 @@ class BrowserCLI:
multi_results = self._collect_multi_browser("group.list", {}) multi_results = self._collect_multi_browser("group.list", {})
if multi_results: if multi_results:
return [ 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,
browser_token=target.token,
)
for target, groups in multi_results for target, groups in multi_results
for group in (groups or []) for group in (groups or [])
] ]
+66 -34
View File
@@ -6,7 +6,6 @@ import click
import sys import sys
import os import os
import json import json
import stat
import shutil import shutil
import re import re
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
@@ -31,11 +30,27 @@ from browser_cli.client import (
REGISTRY_PATH, REGISTRY_PATH,
active_browser_targets, active_browser_targets,
display_browser_name, display_browser_name,
save_remote_token,
) )
from browser_cli.platform import install_base_dir, is_windows from browser_cli.platform import install_base_dir, is_windows
from browser_cli.registry import load_registry
console = Console() 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" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg" EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
@@ -95,15 +110,25 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
raise click.ClickException(f"Browser alias '{alias}' already exists") raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_wrapper_path() -> Path: def _native_host_exe() -> Path:
base_dir = install_base_dir() base = install_base_dir()
if is_windows(): if is_windows():
return base_dir / "libexec" / "native-host.cmd" return base / "libexec" / "browser-cli-native-host.cmd"
return base_dir / "libexec" / "native-host" return base / "libexec" / "browser-cli-native-host"
def _native_host_script_path() -> Path: def _write_native_host_exe(path: Path) -> None:
return _native_host_wrapper_path().with_name("native_host.py") 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(): def _windows_registry_views():
@@ -181,8 +206,12 @@ def main(ctx, browser, remote, token):
ctx.obj["browser_explicit"] = browser is not None ctx.obj["browser_explicit"] = browser is not None
if browser: if browser:
os.environ["BROWSER_CLI_PROFILE"] = browser os.environ["BROWSER_CLI_PROFILE"] = browser
ctx.obj["remote"] = remote
ctx.obj["token"] = token
if remote: if remote:
os.environ["BROWSER_CLI_REMOTE"] = remote os.environ["BROWSER_CLI_REMOTE"] = remote
if token:
save_remote_token(remote, token)
if token: if token:
os.environ["BROWSER_CLI_TOKEN"] = token os.environ["BROWSER_CLI_TOKEN"] = token
@@ -211,14 +240,23 @@ def clients_group(ctx):
if ctx.invoked_subcommand is not None: if ctx.invoked_subcommand is not None:
return return
all_clients = []
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
if remote:
try:
result = send_command("clients.list", profile=(ctx.obj or {}).get("browser"))
for c in (result or []):
c["profile"] = c.get("profile") or (ctx.obj or {}).get("browser") or "remote"
all_clients.append(c)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
else:
profiles: dict[str, str] = {} profiles: dict[str, str] = {}
if REGISTRY_PATH.exists(): if REGISTRY_PATH.exists():
try: profiles = load_registry(REGISTRY_PATH)
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
pass
all_clients = []
for profile_name, sock_path in profiles.items(): for profile_name, sock_path in profiles.items():
display_profile = display_browser_name(profile_name, sock_path) display_profile = display_browser_name(profile_name, sock_path)
try: try:
@@ -235,6 +273,17 @@ def clients_group(ctx):
"extensionVersion": "disconnected", "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, token=target.token)
for c in (result or []):
c["profile"] = target.display_name
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
continue
if not all_clients: if not all_clients:
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]") console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
sys.exit(1) sys.exit(1)
@@ -282,22 +331,8 @@ def cmd_clients_rename(target_browser, alias):
def cmd_install(browser): def cmd_install(browser):
"""Register the native messaging host and print extension load instructions.""" """Register the native messaging host and print extension load instructions."""
# Install wrapper outside PATH — the browser uses the absolute path from the host_exe = _native_host_exe()
# native messaging manifest, so only `browser-cli` needs to be on PATH. _write_native_host_exe(host_exe)
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")
# Load extension # Load extension
ext_urls = { ext_urls = {
@@ -320,14 +355,14 @@ def cmd_install(browser):
manifest = { manifest = {
"name": NATIVE_HOST_NAME, "name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host", "description": "browser-cli native messaging host",
"path": str(wrapper_path), "path": str(host_exe),
"type": "stdio", "type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/"], "allowed_origins": [f"chrome-extension://{extension_id}/"],
} }
installed = [] installed = []
if is_windows(): if is_windows():
manifest_dir = wrapper_path.parent manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True) manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json" manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
@@ -354,10 +389,7 @@ def cmd_install(browser):
console.print(f"[green]✓[/green] Registered native host: {p}") console.print(f"[green]✓[/green] Registered native host: {p}")
else: else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}") 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: {host_exe}")
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"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)") 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]") console.print("\n[green bold]✓ Installation complete![/green bold]")
+144 -21
View File
@@ -19,8 +19,10 @@ from pathlib import Path
from typing import Any from typing import Any
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
from browser_cli.registry import load_registry
REGISTRY_PATH = registry_path() REGISTRY_PATH = registry_path()
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
class BrowserNotConnected(Exception): class BrowserNotConnected(Exception):
@@ -32,6 +34,8 @@ class BrowserTarget:
profile: str profile: str
display_name: str display_name: str
socket_path: str socket_path: str
remote: str | None = None
token: str | None = None
def _active_endpoints(reg: dict) -> dict: def _active_endpoints(reg: dict) -> dict:
@@ -47,17 +51,111 @@ def display_browser_name(profile_name: str, sock_path: str) -> str:
return Path(sock_path).stem or profile_name return Path(sock_path).stem or profile_name
def active_browser_targets() -> list[BrowserTarget]: def _load_remotes() -> dict[str, dict[str, str]]:
if not REGISTRY_PATH.exists(): if not REMOTE_REGISTRY_PATH.exists():
return [] return {}
try: try:
reg = json.loads(REGISTRY_PATH.read_text()) data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception: except Exception:
return [] return {}
return [ if not isinstance(data, dict):
return {}
return {str(endpoint): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
def save_remote_token(endpoint: str, token: str | None) -> None:
"""Persist the auth token for a remote endpoint used by this client."""
if not endpoint or not token:
return
remotes = _load_remotes()
current = remotes.get(endpoint, {})
current["token"] = token
remotes[endpoint] = current
REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True), encoding="utf-8")
try:
REMOTE_REGISTRY_PATH.chmod(0o600)
except OSError:
pass
def token_for_remote(endpoint: str | None) -> str | None:
if not endpoint:
return None
cfg = _load_remotes().get(endpoint) or {}
token = cfg.get("token")
return str(token) if token else None
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, token: str | None = None) -> list[BrowserTarget]:
"""Return browser targets advertised by a single remote endpoint."""
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token)
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,
token=token,
)
)
return targets
def _remote_browser_targets() -> list[BrowserTarget]:
targets: list[BrowserTarget] = []
for endpoint, cfg in _load_remotes().items():
token = str(cfg.get("token") or "") or None
try:
targets.extend(remote_browser_targets(endpoint, token))
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) -> 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) BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_endpoints(reg).items() for profile, sock_path in _active_endpoints(reg).items()
] )
if include_remotes:
targets.extend(_remote_browser_targets())
return targets
def _resolve_socket(profile: str | None = None) -> str: def _resolve_socket(profile: str | None = None) -> str:
@@ -66,17 +164,14 @@ def _resolve_socket(profile: str | None = None) -> str:
if target: if target:
if REGISTRY_PATH.exists(): if REGISTRY_PATH.exists():
try: reg = load_registry(REGISTRY_PATH)
reg = json.loads(REGISTRY_PATH.read_text())
if target in reg: if target in reg:
return reg[target] return reg[target]
except Exception:
pass
return endpoint_for_alias(target) return endpoint_for_alias(target)
# Auto-detect: error when multiple browser instances are active # Auto-detect: error when multiple browser instances are active
try: try:
active = active_browser_targets() active = active_browser_targets(include_remotes=False)
if len(active) > 1: if len(active) > 1:
aliases = [target.profile for target in active] aliases = [target.profile for target in active]
examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases) examples = "\n".join(f" browser-cli --browser {a} ..." for a in aliases)
@@ -98,10 +193,42 @@ def _resolve_socket(profile: str | None = None) -> str:
) )
def _send_remote(endpoint: str, framed: bytes) -> bytes:
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.connect((host, int(port_str)))
sock.sendall(framed)
return _recv_all(sock)
def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
targets = remote_browser_targets(endpoint, token)
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, token: str | None = None) -> Any: def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any:
"""Send a command to the browser and return the response data.""" """Send a command to the browser and return the response data."""
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE") remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") 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
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
msg = { msg = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"command": command, "command": command,
@@ -110,7 +237,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
if remote_endpoint: if remote_endpoint:
if resolved_token: if resolved_token:
msg["token"] = resolved_token msg["token"] = resolved_token
route_profile = profile or os.environ.get("BROWSER_CLI_PROFILE") route_profile = requested_profile
if not route_profile and command != "browser-cli.targets":
route_profile = _auto_route_remote(remote_endpoint, resolved_token)
if route_profile: if route_profile:
msg["_route"] = route_profile msg["_route"] = route_profile
payload = json.dumps(msg).encode("utf-8") payload = json.dumps(msg).encode("utf-8")
@@ -118,13 +247,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
try: try:
if remote_endpoint: if remote_endpoint:
host, _, port_str = remote_endpoint.rpartition(":") response = _send_remote(remote_endpoint, framed)
if not host or not port_str:
raise BrowserNotConnected(f"Invalid remote endpoint '{remote_endpoint}': expected host:port")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((host, int(port_str)))
sock.sendall(framed)
response = _recv_all(sock)
elif is_windows(): elif is_windows():
sock_path = _resolve_socket(profile) sock_path = _resolve_socket(profile)
with PipeClient(sock_path, family="AF_PIPE") as conn: with PipeClient(sock_path, family="AF_PIPE") as conn:
+11 -5
View File
@@ -1,5 +1,5 @@
import click 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.console import Console
from rich.table import Table from rich.table import Table
@@ -17,8 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1) raise SystemExit(1)
def _handle_multi(command, args=None, profile=None): def _handle_multi(command, args=None, profile=None, remote=None, token=None):
try: try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile) return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
return None return None
@@ -28,8 +30,12 @@ def _multi_browser_targets():
root = click.get_current_context().find_root() root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"): if root.obj.get("browser_explicit"):
return [] return []
remote = root.obj.get("remote")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
else:
targets = active_browser_targets() targets = active_browser_targets()
if len(targets) <= 1: if len(targets) <= 1 and not any(target.remote for target in targets):
return [] return []
return targets return targets
@@ -71,7 +77,7 @@ def group_list():
if targets: if targets:
groups = [] groups = []
for target in targets: for target in targets:
result = _handle_multi("group.list", profile=target.profile) result = _handle_multi("group.list", profile=target.profile, remote=target.remote, token=target.token)
if result is None: if result is None:
continue continue
groups.extend({**group, "browser": target.display_name} for group in result) groups.extend({**group, "browser": target.display_name} for group in result)
@@ -104,7 +110,7 @@ def group_count():
total = 0 total = 0
rows = 0 rows = 0
for target in targets: for target in targets:
count = _handle_multi("group.count", profile=target.profile) count = _handle_multi("group.count", profile=target.profile, remote=target.remote, token=target.token)
if count is None: if count is None:
continue continue
count = int(count or 0) count = int(count or 0)
+11
View File
@@ -56,6 +56,17 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, serv
_log(addr, command, None, "DENIED", "bad token") _log(addr, command, None, "DENIED", "bad token")
return 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
resolved_profile = msg.get("_route") or profile resolved_profile = msg.get("_route") or profile
strip = {"token", "_route"} strip = {"token", "_route"}
+10 -4
View File
@@ -1,5 +1,5 @@
import click 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 from rich.console import Console
console = Console() console = Console()
@@ -16,8 +16,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1) raise SystemExit(1)
def _handle_multi(command, args=None, profile=None): def _handle_multi(command, args=None, profile=None, remote=None, token=None):
try: try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile) return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
return None return None
@@ -27,8 +29,12 @@ def _multi_browser_targets():
root = click.get_current_context().find_root() root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"): if root.obj.get("browser_explicit"):
return [] return []
remote = root.obj.get("remote")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
else:
targets = active_browser_targets() targets = active_browser_targets()
if len(targets) <= 1: if len(targets) <= 1 and not any(target.remote for target in targets):
return [] return []
return targets return targets
@@ -92,7 +98,7 @@ def session_list():
if targets: if targets:
sessions = [] sessions = []
for target in targets: for target in targets:
result = _handle_multi("session.list", profile=target.profile) result = _handle_multi("session.list", profile=target.profile, remote=target.remote, token=target.token)
if result is None: if result is None:
continue continue
sessions.extend({**session, "browser": target.display_name} for session in result) sessions.extend({**session, "browser": target.display_name} for session in result)
+11 -5
View File
@@ -1,6 +1,6 @@
import base64 import base64
import click 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.console import Console
from rich.table import Table from rich.table import Table
@@ -18,8 +18,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1) raise SystemExit(1)
def _handle_multi(command, args=None, profile=None): def _handle_multi(command, args=None, profile=None, remote=None, token=None):
try: try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile) return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
return None return None
@@ -29,8 +31,12 @@ def _multi_browser_targets():
root = click.get_current_context().find_root() root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"): if root.obj.get("browser_explicit"):
return [] return []
remote = root.obj.get("remote")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
else:
targets = active_browser_targets() targets = active_browser_targets()
if len(targets) <= 1: if len(targets) <= 1 and not any(target.remote for target in targets):
return [] return []
return targets return targets
@@ -76,7 +82,7 @@ def tabs_list():
if targets: if targets:
tabs = [] tabs = []
for target in targets: for target in targets:
result = _handle_multi("tabs.list", profile=target.profile) result = _handle_multi("tabs.list", profile=target.profile, remote=target.remote, token=target.token)
if result is None: if result is None:
continue continue
tabs.extend({**tab, "browser": target.display_name} for tab in result) tabs.extend({**tab, "browser": target.display_name} for tab in result)
@@ -163,7 +169,7 @@ def tabs_count(pattern):
total = 0 total = 0
rows = 0 rows = 0
for target in targets: 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, token=target.token)
if count is None: if count is None:
continue continue
count = int(count or 0) count = int(count or 0)
+10 -4
View File
@@ -1,5 +1,5 @@
import click 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.console import Console
from rich.table import Table from rich.table import Table
@@ -17,8 +17,10 @@ def _handle(command, args=None, profile=None):
raise SystemExit(1) raise SystemExit(1)
def _handle_multi(command, args=None, profile=None): def _handle_multi(command, args=None, profile=None, remote=None, token=None):
try: try:
if remote:
return send_command(command, args or {}, profile=profile, remote=remote, token=token)
return send_command(command, args or {}, profile=profile) return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError): except (BrowserNotConnected, RuntimeError):
return None return None
@@ -28,8 +30,12 @@ def _multi_browser_targets():
root = click.get_current_context().find_root() root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"): if root.obj.get("browser_explicit"):
return [] return []
remote = root.obj.get("remote")
if remote:
targets = remote_browser_targets(remote, root.obj.get("token"))
else:
targets = active_browser_targets() targets = active_browser_targets()
if len(targets) <= 1: if len(targets) <= 1 and not any(target.remote for target in targets):
return [] return []
return targets return targets
@@ -69,7 +75,7 @@ def windows_list():
if targets: if targets:
windows = [] windows = []
for target in targets: for target in targets:
result = _handle_multi("windows.list", profile=target.profile) result = _handle_multi("windows.list", profile=target.profile, remote=target.remote, token=target.token)
if result is None: if result is None:
continue continue
windows.extend({**window, "browser": target.display_name} for window in result) windows.extend({**window, "browser": target.display_name} for window in result)
+86 -23
View File
@@ -18,11 +18,31 @@ from multiprocessing.connection import Listener
from pathlib import Path from pathlib import Path
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir 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 SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {} PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock() PENDING_LOCK = threading.Lock()
WRITE_LOCK = threading.Lock()
REGISTRY_PATH = registry_path() 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) --- # --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
@@ -48,20 +68,14 @@ def write_native_message(stream, msg: dict) -> None:
def _registry_add(alias: str, sock_path: str) -> None: def _registry_add(alias: str, sock_path: str) -> None:
try: try:
reg = json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {} update_registry(alias, sock_path, REGISTRY_PATH)
reg[alias] = sock_path
REGISTRY_PATH.write_text(json.dumps(reg))
except Exception: except Exception:
pass pass
def _registry_remove(alias: str) -> None: def _registry_remove(alias: str) -> None:
try: try:
if not REGISTRY_PATH.exists(): update_registry(alias, None, REGISTRY_PATH)
return
reg = json.loads(REGISTRY_PATH.read_text())
reg.pop(alias, None)
REGISTRY_PATH.write_text(json.dumps(reg))
except Exception: except Exception:
pass pass
@@ -143,21 +157,7 @@ def handle_cli_connection(conn, listener=None) -> None:
if "id" not in cmd: if "id" not in cmd:
cmd["id"] = str(uuid.uuid4()) cmd["id"] = str(uuid.uuid4())
msg_id = cmd["id"] result = _handle_browser_command(cmd)
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)
response = json.dumps(result).encode("utf-8") response = json.dumps(result).encode("utf-8")
if is_windows(): if is_windows():
@@ -179,6 +179,69 @@ def handle_cli_connection(conn, listener=None) -> None:
listener.close() 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
while True:
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) --- # --- Socket helpers (length-prefixed framing) ---
def _send_all(conn: socket.socket, data: bytes) -> None: def _send_all(conn: socket.socket, data: bytes) -> None:
+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)
-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, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.7.1", "version": "0.8.5",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "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 ──────────────────────────────────────────────────────────────────────
+127
View File
@@ -0,0 +1,127 @@
// @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 });
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 (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 || "about:blank").hostname;
const db = new URL(b.url || "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 [focused] = await chrome.windows.getAll({ populate: false });
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.0.326",
"esbuild": "^0.25.3",
"typescript": "^5.8.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/chrome": {
"version": "0.0.326",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.326.tgz",
"integrity": "sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw==",
"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.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"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.0.326",
"esbuild": "^0.25.3",
"typescript": "^5.8.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.8.0" version = "0.8.5"
description = "Control your real running browser from the terminal via a browser extension" description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+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)"
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.""" """Returns a connected send_command callable for the testing profile, or skips the test."""
try: try:
send_command("tabs.list", profile=TEST_BROWSER_PROFILE) send_command("tabs.list", profile=TEST_BROWSER_PROFILE)
except BrowserNotConnected: except (BrowserNotConnected, RuntimeError) as e:
pytest.skip( 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): def _browser(command, args=None):
+50 -1
View File
@@ -5,6 +5,7 @@ These tests mock `send_command` so no live browser connection is required.
import pytest import pytest
from unittest.mock import MagicMock, patch, call from unittest.mock import MagicMock, patch, call
import browser_cli
from browser_cli import BrowserCLI, BrowserCounts, Tab, Group from browser_cli import BrowserCLI, BrowserCounts, Tab, Group
from browser_cli.client import BrowserNotConnected, BrowserTarget from browser_cli.client import BrowserNotConnected, BrowserTarget
@@ -63,6 +64,12 @@ class TestBrowserCLIInit:
b = BrowserCLI(browser="chrome") b = BrowserCLI(browser="chrome")
assert b._browser == "chrome" assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", token="secret")
assert b._browser == "work"
assert b._remote == "host:8765"
assert b._token == "secret"
# ── Internal factories ──────────────────────────────────────────────────────── # ── Internal factories ────────────────────────────────────────────────────────
@@ -164,6 +171,11 @@ class TestNavigation:
b_profile.reload() b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None) mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", token="secret")
b.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret")
# ── Search ──────────────────────────────────────────────────────────────────── # ── Search ────────────────────────────────────────────────────────────────────
@@ -274,7 +286,6 @@ class TestTabs:
def test_tabs_filter_predicate(self, b, mock_send): def test_tabs_filter_predicate(self, b, mock_send):
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}] mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}]
tabs = b.tabs_filter(lambda tab: "youtube" in tab.url) tabs = b.tabs_filter(lambda tab: "youtube" in tab.url)
print(tabs)
assert [tab.id for tab in tabs] == [11] assert [tab.id for tab in tabs] == [11]
def test_tabs_filter_list_transformer(self, b, mock_send): def test_tabs_filter_list_transformer(self, b, mock_send):
@@ -311,6 +322,25 @@ class TestTabs:
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None), call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None),
] ]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret")
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", token="secret")],
):
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", token="secret"),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret"),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send): def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
@@ -415,6 +445,25 @@ class TestGroups:
call("group.close", {"groupId": 99}, profile="work", remote=None, token=None), call("group.close", {"groupId": 99}, profile="work", remote=None, token=None),
] ]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", token="secret")
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", token="secret")],
):
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", token="secret"),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret"),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send): def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch( with patch(
"browser_cli.active_browser_targets", "browser_cli.active_browser_targets",
+136 -34
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import os
import sys import sys
from click.testing import CliRunner 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 assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch): def test_install_windows_registers_native_host(tmp_path):
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")
writes = [] writes = []
class FakeKey: class FakeKey:
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, _exc_type, _exc, _tb):
def __exit__(self, exc_type, exc, tb):
return False return False
fake_winreg = SimpleNamespace( fake_winreg = SimpleNamespace(
@@ -103,43 +97,104 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
KEY_WOW64_32KEY=0x0200, KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100, KEY_WOW64_64KEY=0x0100,
REG_SZ=1, 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): host_exe = tmp_path / "browser-cli-native-host.exe"
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))
with patch("browser_cli.cli.is_windows", return_value=True), patch( with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path "browser_cli.cli._native_host_exe", return_value=host_exe
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch( ), patch("browser_cli.cli._write_native_host_exe"), patch(
"browser_cli.cli.shutil.copy2" "browser_cli.cli.Path.write_text"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict( ), patch.dict(sys.modules, {"winreg": fake_winreg}):
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")
result = CliRunner().invoke(main, ["install", "edge"]) result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0 assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes) 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 def test_write_native_host_exe_unix(tmp_path):
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args] from browser_cli.cli import _write_native_host_exe
assert any("@echo off" in text for text in wrapper_writes)
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(): 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"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "No browser clients found" in result.output 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):
assert command == "clients.list"
assert profile is None
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, patch(
"browser_cli.cli.save_remote_token"
):
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--token", "test", "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")
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock" default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
@@ -160,7 +215,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( with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
"browser_cli.cli.send_command", side_effect=fake_send_command "browser_cli.cli.send_command", side_effect=fake_send_command
): ), patch("browser_cli.cli.active_browser_targets", return_value=[]):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -190,6 +245,25 @@ def test_tabs_list_multi_browser_shows_browser_column():
assert "work" in result.output 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", token="secret")],
), patch(
"browser_cli.commands.tabs.send_command",
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
) as send_command, patch("browser_cli.cli.save_remote_token"):
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "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", token="secret")
def test_tabs_list_with_explicit_browser_does_not_show_browser_column(): def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
with patch( with patch(
"browser_cli.commands.tabs.active_browser_targets", "browser_cli.commands.tabs.active_browser_targets",
@@ -288,7 +362,6 @@ def test_groups_move_accepts_left_short_alias():
) )
def test_windows_list_multi_browser_shows_browser_column(): def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None): def fake_send_command(command, args=None, profile=None):
assert command == "windows.list" assert command == "windows.list"
@@ -524,3 +597,32 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
"- Unternehmensdaten → RAG → KI-Orchestrierung →\n" "- Unternehmensdaten → RAG → KI-Orchestrierung →\n"
" Local LLMs / API Modelle / Spezialmodelle" " Local LLMs / API Modelle / Spezialmodelle"
) in markdown ) in markdown
def test_remote_token_is_saved_when_passed_on_cli():
endpoint = "browser-host.example:8765"
with patch("browser_cli.cli.save_remote_token") as save_remote_token:
result = CliRunner().invoke(main, ["--remote", endpoint, "--token", "secret", "completion", "bash", "--script"])
assert result.exit_code == 0
save_remote_token.assert_called_once_with(endpoint, "secret")
def test_tabs_list_multi_browser_queries_remote_target():
endpoint = "browser-host.example:8765"
remote_target = BrowserTarget(
"work",
"browser-host.example:work",
"",
remote=endpoint,
token="secret",
)
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, token="secret")
assert "browser-host.example:work" in result.output
+183 -3
View File
@@ -3,7 +3,17 @@ from pathlib import Path
import pytest 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,
save_remote_token,
send_command,
remote_target_for_alias,
token_for_remote,
)
from browser_cli.platform import endpoint_for_alias from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch): def test_resolve_socket_raises_when_registry_missing(monkeypatch):
@@ -63,7 +73,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_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 len(targets) == 1
assert targets[0].profile == "work" assert targets[0].profile == "work"
@@ -77,7 +87,177 @@ 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.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True) 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 len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work" assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
remotes_path = tmp_path / "remotes.json"
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
endpoint = "browser-host.example:8765"
save_remote_token(endpoint, "secret-token")
assert token_for_remote(endpoint) == "secret-token"
assert json.loads(remotes_path.read_text(encoding="utf-8")) == {
endpoint: {"token": "secret-token"}
}
def test_send_command_auto_routes_single_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
sent = {}
monkeypatch.setattr(
"browser_cli.client.remote_browser_targets",
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
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", token="secret") == "ok"
assert sent["_route"] == "work"
assert sent["token"] == "secret"
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
monkeypatch.delenv("BROWSER_CLI_TOKEN", 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", token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
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 sent["token"] == "secret"
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", token="secret")],
)
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, token="secret")],
)
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.delenv("BROWSER_CLI_TOKEN", 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, token="secret")],
)
def fake_send_remote(endpoint, framed):
payload_len = int.from_bytes(framed[:4], "little")
msg = json.loads(framed[4:4 + payload_len])
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 sent["token"] == "secret"
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", token="secret"),
BrowserTarget("work", "host:work", "", remote="host:8765", token="secret"),
],
)
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, token=None: [
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
],
)
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
send_command("tabs.list", remote="host:8765", token="secret")
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: {"token": "secret-token"}}), 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, token=None):
assert command == "browser-cli.targets"
assert remote == endpoint
assert token == "secret-token"
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
assert targets[0].token == "secret-token"
-2
View File
@@ -1,6 +1,4 @@
"""Tests for dom.* commands (require an http/https active tab).""" """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): 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).""" """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): 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) 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.""" """Tests for group.* commands."""
import pytest import pytest
from browser_cli.client import send_command
def test_group_list(browser): 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} assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
native_host.PENDING.clear() 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.""" """Tests for navigate.* commands."""
import pytest
from browser_cli.client import send_command
def test_nav_open_and_close(browser): 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.""" """Tests for session.* commands."""
import time import time
import pytest
from browser_cli.client import send_command from browser_cli.client import send_command
from tests.conftest import TEST_BROWSER_PROFILE from tests.conftest import TEST_BROWSER_PROFILE
-1
View File
@@ -1,6 +1,5 @@
"""Tests for tabs.* commands.""" """Tests for tabs.* commands."""
import pytest import pytest
from browser_cli.client import send_command
def test_tabs_list(browser): def test_tabs_list(browser):
-2
View File
@@ -1,6 +1,4 @@
"""Tests for windows.* commands.""" """Tests for windows.* commands."""
import pytest
from browser_cli.client import send_command
def test_windows_list(browser): 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
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.8.0" version = "0.8.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },