Compare commits

...

10 Commits

Author SHA1 Message Date
daniel156161 edf9056430 show mute status correctly when tab mute and add to get single tab status
Package Extension / package-extension (push) Successful in 17s
Build & Publish Package / publish (push) Successful in 29s
Testing / test (push) Failing after 26s
2026-04-13 21:35:25 +02:00
daniel156161 c494e76fe2 allow to mute and unmute tabs and get mute status into tab info
Testing / test (push) Failing after 34s
2026-04-13 21:19:27 +02:00
daniel156161 5150933319 change that tab open change url inplace and get active tab from window id
Testing / test (push) Successful in 29s
Package Extension / package-extension (push) Successful in 11s
Build & Publish Package / publish (push) Successful in 43s
2026-04-13 19:50:46 +02:00
daniel156161 9dbe57c66c implement windows support of the extension
Testing / test (push) Successful in 47s
2026-04-13 11:02:54 +02:00
daniel156161 080ca6da6d update uv.lock to new version of browser-cli
Testing / test (push) Failing after 11m8s
2026-04-13 08:06:15 +02:00
daniel156161 d0c1d7c226 update README 2026-04-13 08:05:43 +02:00
daniel156161 2a38997946 add --left/--right commands into move and add shorter aliases to move flags 2026-04-13 08:04:58 +02:00
daniel156161 a1038d5817 fix that alias can only exist once and when dublicate give error
Testing / test (push) Successful in 39s
Package Extension / package-extension (push) Successful in 23s
Build & Publish Package / publish (push) Successful in 33s
2026-04-12 17:31:49 +02:00
daniel156161 64d804cf32 adding better markdown extracting and filter out to not have broken staff, allow that session can list over multiple browsers 2026-04-12 17:10:19 +02:00
daniel156161 51054422fb fix demo.sh script that the creation of a group now works again
Testing / test (push) Successful in 21s
2026-04-10 13:45:46 +02:00
23 changed files with 1523 additions and 114 deletions
+10 -6
View File
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local socket.
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
---
@@ -17,7 +17,7 @@ It works by pairing a small Chrome/Brave extension with a Python CLI tool. The e
```
terminal / python script
Unix socket (/tmp/browser-cli.sock)
Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
Native Messaging Host (Python process, launched by the browser)
@@ -32,7 +32,7 @@ terminal / python script
1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup.
2. The browser launches the native host Python process (registered in the OS).
3. The native host opens a Unix socket at `/tmp/browser-cli.sock`.
3. The native host opens a local IPC endpoint for the CLI.
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
@@ -70,7 +70,7 @@ The `install` command will:
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy. On Windows the install command also registers the host in the current user's Registry for the selected browser.
---
@@ -81,7 +81,7 @@ browser-cli/
├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── cli.py # Click CLI entry point
│ ├── client.py # Unix socket client used by CLI and API
│ ├── client.py # Local IPC client used by CLI and API
│ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/
@@ -122,7 +122,7 @@ browser-cli/
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, and `windows list` are the only commands that aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. `tabs list`, `tabs count`, `groups list`, `groups count`, `windows list`, and `session list` aggregate across all active browsers when `--browser` is omitted; in that mode they show the source browser alias or UUID. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli clients rename --browser <current-alias> <new-alias>`. Closed browsers are removed from the client registry automatically.
Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct.
@@ -215,7 +215,11 @@ browser-cli groups add-tab 42 https://example.com # by group ID
browser-cli groups close 42 # ungroup the group
browser-cli groups move research --forward # move group right
browser-cli groups move research --right # same as --forward
browser-cli groups move research -r # short right alias
browser-cli groups move 42 --backward # move group left
browser-cli groups move 42 --left # same as --backward
browser-cli groups move 42 -l # short left alias
```
### Windows
+40
View File
@@ -77,6 +77,7 @@ class BrowserCLI:
id=data["id"],
window_id=data.get("windowId", 0),
active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "",
url=data.get("url") or "",
group_id=data.get("groupId") or None,
@@ -117,6 +118,10 @@ class BrowserCLI:
def focus_url(self, pattern: str) -> None:
self._cmd("navigate.focus", {"pattern": pattern})
def navigate_tab(self, tab_id: int, url: str) -> None:
"""Navigate a specific tab to *url*."""
self._cmd("navigate.to", {"tabId": tab_id, "url": url})
# ── Search ────────────────────────────────────────────────────────────
def search(
@@ -168,6 +173,30 @@ class BrowserCLI:
"""Switch browser focus to a tab by ID."""
self._cmd("tabs.active", {"tabId": tab_id})
def tabs_status(self, tab_id: int | None = None) -> Tab:
"""Return status for the active tab or a specific tab."""
data = self._cmd("tabs.status", {"tabId": tab_id})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("No tab status returned")
return self._make_tab(data)
def tabs_mute(self, tab_id: int | None = None) -> int:
"""Mute the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.mute", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def tabs_unmute(self, tab_id: int | None = None) -> int:
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
result = self._cmd("tabs.unmute", {"tabId": tab_id})
return result.get("tabId", tab_id) if isinstance(result, dict) else int(tab_id or 0)
def window_active_tab(self, window_id: int) -> Tab:
"""Return active tab for a specific browser window."""
data = self._cmd("tabs.active_in_window", {"windowId": window_id})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError(f"No active tab found for window {window_id}")
return self._make_tab(data)
def tabs_filter(self, pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
"""Return tabs filtered by pattern or a Python callable."""
if isinstance(pattern_or_filter, str):
@@ -346,6 +375,17 @@ class BrowserCLI:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b})
def session_list(self) -> list[dict]:
"""Return saved sessions.
In implicit multi-browser mode each session dict includes a ``browser`` key.
"""
multi_results = self._collect_multi_browser("session.list", {})
if multi_results:
return [
{**session, "browser": target.display_name}
for target, sessions in multi_results
for session in (sessions or [])
]
return self._cmd("session.list", {})
def session_remove(self, name: str) -> None:
+103 -25
View File
@@ -21,7 +21,14 @@ from browser_cli.commands.dom import dom_group
from browser_cli.commands.extract import extract_group
from browser_cli.commands.session import session_group
from browser_cli.commands.search import search_group
from browser_cli.client import send_command, BrowserNotConnected, REGISTRY_PATH, display_browser_name
from browser_cli.client import (
send_command,
BrowserNotConnected,
REGISTRY_PATH,
active_browser_targets,
display_browser_name,
)
from browser_cli.platform import install_base_dir, is_windows
console = Console()
@@ -50,12 +57,43 @@ NATIVE_HOST_DIRS = {
},
}
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
"chromium": [r"Software\Chromium\NativeMessagingHosts"],
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser:
return target_browser
active = active_browser_targets()
if len(active) == 1:
return active[0].profile
return None
def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None:
target_profile = _rename_target_profile(target_browser)
profiles: dict[str, str] = {}
if REGISTRY_PATH.exists():
try:
profiles = json.loads(REGISTRY_PATH.read_text())
except Exception:
profiles = {}
if alias in profiles and alias != target_profile:
raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_wrapper_path() -> Path:
if sys.platform == "darwin":
base_dir = Path.home() / "Library" / "Application Support" / "browser-cli"
else:
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli"
base_dir = install_base_dir()
if is_windows():
return base_dir / "libexec" / "native-host.cmd"
return base_dir / "libexec" / "native-host"
@@ -63,6 +101,30 @@ def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py")
def _windows_registry_views():
import winreg
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
import winreg
installed = []
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
for view in _windows_registry_views():
try:
access = winreg.KEY_WRITE | view
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
with key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
installed.append(f"HKCU\\{full_key}")
except OSError as e:
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
return installed
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
@@ -184,6 +246,7 @@ main.add_command(clients_group)
def cmd_clients_rename(target_browser, alias):
"""Set the profile alias used to identify this browser instance."""
try:
_ensure_unique_browser_alias(alias, target_browser)
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
@@ -204,12 +267,16 @@ def cmd_install(browser):
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)
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)
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")
# Ask for extension ID
ext_urls = {
@@ -238,30 +305,41 @@ def cmd_install(browser):
"allowed_origins": [f"chrome-extension://{extension_id}/"],
}
# Write to OS native messaging dirs
platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform]
installed = []
for d in dirs:
try:
d.mkdir(parents=True, exist_ok=True)
manifest_path = d / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {d}: {e}[/yellow]")
if is_windows():
manifest_dir = wrapper_path.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed = _register_windows_native_host(browser, manifest_path)
else:
platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform]
for d in dirs:
try:
d.mkdir(parents=True, exist_ok=True)
manifest_path = d / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {d}: {e}[/yellow]")
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
if is_windows():
console.print(f"[green]✓[/green] Registered native host: {p}")
else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (Cmd/Ctrl+Q, 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(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
+22 -14
View File
@@ -1,11 +1,11 @@
"""
Unix socket client sends commands to the native host relay socket.
Used by both the CLI and the public Python API.
Local IPC client sends commands to native host relay endpoint.
Used by both CLI and public Python API.
Profile selection order:
1. Explicit `profile` argument to send_command()
2. BROWSER_CLI_PROFILE environment variable
3. First entry in /tmp/.browser_cli/registry.json
3. First entry in runtime registry
4. Otherwise, no browser can be resolved automatically
"""
import json
@@ -14,11 +14,13 @@ import socket
import struct
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path
from typing import Any
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
REGISTRY_PATH = registry_path()
class BrowserNotConnected(Exception):
@@ -32,8 +34,10 @@ class BrowserTarget:
socket_path: str
def _active_sockets(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk."""
def _active_endpoints(reg: dict) -> dict:
"""Return only entries whose endpoint appears reachable."""
if is_windows():
return dict(reg)
return {k: v for k, v in reg.items() if Path(v).exists()}
@@ -52,7 +56,7 @@ def active_browser_targets() -> list[BrowserTarget]:
return []
return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_sockets(reg).items()
for profile, sock_path in _active_endpoints(reg).items()
]
@@ -68,8 +72,7 @@ def _resolve_socket(profile: str | None = None) -> str:
return reg[target]
except Exception:
pass
safe = target.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(target)
# Auto-detect: error when multiple browser instances are active
try:
@@ -107,10 +110,15 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
framed = struct.pack("<I", len(payload)) + payload
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
response = _recv_all(sock)
if is_windows():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
profile_hint = f" (profile: {profile})" if profile else ""
raise BrowserNotConnected(
+421 -1
View File
@@ -1,10 +1,426 @@
import click
import json
import re
from html.parser import HTMLParser
import click
from browser_cli.client import send_command, BrowserNotConnected
from rich.console import Console
from rich.table import Table
console = Console()
_FENCE_RE = re.compile(r"```(?:[^\n`]*)\n.*?\n```", re.DOTALL)
_ESCAPED_MARKDOWN_RE = re.compile(r"\\([_-])")
_TABLE_SEPARATOR_RE = re.compile(r"^\|(?:\s*:?-{3,}:?\s*\|)+\s*$")
class _HtmlNode:
def __init__(self, tag=None, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.text = text
self.children = []
class _HtmlTreeBuilder(HTMLParser):
_VOID_TAGS = {"br", "hr", "img"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = _HtmlNode(tag="document")
self._stack = [self.root]
def handle_starttag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
if node.tag not in self._VOID_TAGS:
self._stack.append(node)
def handle_startendtag(self, tag, attrs):
node = _HtmlNode(tag=tag.lower(), attrs=dict(attrs))
self._stack[-1].children.append(node)
def handle_endtag(self, tag):
lowered = tag.lower()
for index in range(len(self._stack) - 1, 0, -1):
if self._stack[index].tag == lowered:
del self._stack[index:]
break
def handle_data(self, data):
if data:
self._stack[-1].children.append(_HtmlNode(text=data))
def _normalize_text(value):
return re.sub(r"\s+", " ", value or "").strip()
def _normalize_inline(value):
value = value.replace("\xa0", " ")
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r" *\n *", "\n", value)
return value.strip()
def _collapse_blank_lines(value):
value = re.sub(r"[ \t]+\n", "\n", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _escape_markdown(text):
return re.sub(r"([\\`[\]])", r"\\\1", text)
def _escape_table_cell(text):
return text.replace("|", r"\|").replace("\n", " ").strip()
def _iter_descendants(node):
for child in getattr(node, "children", []):
yield child
yield from _iter_descendants(child)
def _has_class(node, class_name):
classes = (node.attrs.get("class") or "").split()
return class_name in classes
def _is_code_block_node(node):
if not node or not node.tag:
return False
if node.attrs.get("data-is-code-block-view") == "true":
return True
return node.tag == "pre"
def _inline_text(node):
if node.text is not None:
return _escape_markdown(node.text)
if not node.tag:
return ""
tag = node.tag
if tag == "br":
return "\n"
if tag == "img":
src = node.attrs.get("src") or ""
alt = _normalize_text(node.attrs.get("alt") or "")
if not src:
return ""
return f"![{_escape_markdown(alt)}]({src})" if alt else f"![]({src})"
if tag == "a":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
href = node.attrs.get("href") or ""
return f"[{text or href}]({href})" if href else text
if tag == "code":
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"`{text.replace('`', r'\\`')}`" if text else ""
if tag in {"strong", "b"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"**{text}**" if text else ""
if tag in {"em", "i"}:
text = _normalize_inline("".join(_inline_text(child) for child in node.children))
return f"*{text}*" if text else ""
chunks = []
for child in node.children:
rendered = _inline_text(child)
if rendered:
chunks.append(rendered)
if child.tag in {"p", "div", "table", "ul", "ol", "pre"}:
chunks.append("\n")
return "".join(chunks)
def _text_block(node):
return _collapse_blank_lines(_normalize_inline("".join(_inline_text(child) for child in node.children)))
def _inner_text_preserve(node):
if node.text is not None:
return node.text
if not node.tag:
return ""
if node.tag == "br":
return ""
return "".join(_inner_text_preserve(child) for child in node.children)
def _table_to_markdown(node):
rows = []
for descendant in _iter_descendants(node):
if descendant.tag != "tr":
continue
row = []
for cell in descendant.children:
if cell.tag in {"td", "th"}:
row.append(_escape_table_cell(_text_block(cell)))
if row:
rows.append(row)
if not rows:
return ""
widths = max(len(row) for row in rows)
normalized_rows = [row + [""] * (widths - len(row)) for row in rows]
headers = normalized_rows[0]
body_rows = normalized_rows[1:]
first_row_blank = all(not cell.strip() for cell in headers)
if first_row_blank and len(normalized_rows) > 1:
headers = normalized_rows[1]
body_rows = normalized_rows[2:]
has_thead = any(child.tag == "thead" for child in node.children)
first_row = next((child for child in _iter_descendants(node) if child.tag == "tr"), None)
first_row_has_th = bool(first_row and any(child.tag == "th" for child in first_row.children))
if not (has_thead or first_row_has_th or first_row_blank):
headers = [""] * widths
body_rows = normalized_rows
separator = ["---"] * widths
lines = [
f"| {' | '.join(headers)} |",
f"| {' | '.join(separator)} |",
]
lines.extend(f"| {' | '.join(row)} |" for row in body_rows)
return "\n".join(lines)
def _list_to_markdown(node, depth=0):
ordered = node.tag == "ol"
items = []
index = 1
for child in node.children:
if child.tag != "li":
continue
marker = f"{index}. " if ordered else "- "
index += 1
content = []
nested = []
for item_child in child.children:
if item_child.tag in {"ul", "ol"}:
nested.append(_list_to_markdown(item_child, depth + 1))
else:
content.append(_inline_text(item_child))
line = _collapse_blank_lines(_normalize_inline("".join(content)))
indent = " " * depth
if line:
line_parts = line.splitlines()
items.append(f"{indent}{marker}{line_parts[0]}")
continuation_indent = f"{indent}{' ' * len(marker)}"
items.extend(f"{continuation_indent}{part}" for part in line_parts[1:])
items.extend(block for block in nested if block)
return "\n".join(items)
def _code_block_to_markdown(node):
if node.tag == "pre":
text = _inner_text_preserve(node).rstrip("\n")
return f"```\n{text}\n```" if text else ""
lines = []
for descendant in _iter_descendants(node):
if descendant.tag and _has_class(descendant, "cm-line"):
lines.append(_inner_text_preserve(descendant))
code = "\n".join(lines).rstrip("\n")
return f"```\n{code}\n```" if code else ""
def _block_to_markdown(node):
if node.text is not None:
return _normalize_text(node.text)
if not node.tag:
return ""
if _is_code_block_node(node):
return _code_block_to_markdown(node)
if node.tag == "table":
return _table_to_markdown(node)
if node.tag in {"ul", "ol"}:
return _list_to_markdown(node)
if re.fullmatch(r"h[1-6]", node.tag):
text = _text_block(node)
return f"{'#' * int(node.tag[1])} {text}" if text else ""
if node.tag in {"p", "figcaption"}:
return _text_block(node)
if node.tag == "blockquote":
content = _collapse_blank_lines("\n\n".join(filter(None, (_block_to_markdown(child) for child in node.children))))
return "\n".join(f"> {line}" if line else ">" for line in content.splitlines()) if content else ""
if node.tag == "hr":
return "---"
if node.tag == "img":
return _inline_text(node)
child_blocks = [block for block in (_block_to_markdown(child) for child in node.children) if block]
if child_blocks:
return _collapse_blank_lines("\n\n".join(child_blocks))
return _text_block(node)
def _parse_table_row(line):
stripped = line.strip()
if not stripped.startswith("|") or not stripped.endswith("|"):
return None
return [cell.strip() for cell in stripped.strip("|").split("|")]
def _repair_table_headers(lines):
repaired = []
index = 0
while index < len(lines):
if (
index + 2 < len(lines)
and _parse_table_row(lines[index]) is not None
and _TABLE_SEPARATOR_RE.match(lines[index + 1].strip())
and _parse_table_row(lines[index + 2]) is not None
):
first = _parse_table_row(lines[index])
third = _parse_table_row(lines[index + 2])
if first and all(not cell for cell in first) and any(cell for cell in third):
repaired.append(lines[index + 2].strip())
repaired.append(lines[index + 1].strip())
index += 3
continue
repaired.append(lines[index].strip())
index += 1
return repaired
def _repair_list_continuations(lines):
repaired = []
previous_was_list_item = False
previous_continuation_indent = ""
for line in lines:
stripped = line.strip()
list_match = re.match(r"^(\s*)([-*+]|\d+\.)\s+.+$", stripped)
is_markdown_block_start = (
not stripped
or stripped.startswith(("```", "#", ">", "|"))
or _TABLE_SEPARATOR_RE.match(stripped)
or re.match(r"^(\s*)([-*+]|\d+\.)\s+", stripped)
)
if previous_was_list_item and stripped and not is_markdown_block_start:
repaired.append(f"{previous_continuation_indent}{stripped}")
previous_was_list_item = False
continue
repaired.append(stripped)
if list_match:
marker = list_match.group(2)
base_indent = list_match.group(1)
previous_continuation_indent = f"{base_indent}{' ' * (len(marker) + 1)}"
previous_was_list_item = True
else:
previous_was_list_item = False
return repaired
def _repair_flattened_diagram(text):
if "\n" in text:
return text
if sum(text.count(char) for char in "│▼├└") < 2:
return text
text = re.sub(r"\s{2,}([│▼])", r"\n \1", text)
text = re.sub(r"([│▼])\s{2,}", r"\1\n", text)
text = re.sub(r"([│▼])(?=[^\s\n│▼├└])", r"\1\n", text)
text = re.sub(r"(?<=[^\s\n])([├└])", r"\n\1", text)
text = re.sub(r"([^\s\n])(\()", r"\1\n\2", text)
return "\n".join(line.rstrip() for line in text.splitlines() if line.strip())
def _convert_dash_lists_to_branches(lines):
converted = []
index = 0
while index < len(lines):
match = re.match(r"^(\s*)-\s+(.*)$", lines[index])
if not match:
converted.append(lines[index])
index += 1
continue
indent = match.group(1)
items = []
while index < len(lines):
next_match = re.match(rf"^{re.escape(indent)}-\s+(.*)$", lines[index])
if not next_match:
break
items.append(next_match.group(1))
index += 1
for item_index, item in enumerate(items):
branch = "" if item_index == len(items) - 1 else ""
converted.append(f"{indent}{branch} {item}")
return converted
def _clean_code_block(code):
lines = [line.rstrip() for line in code.splitlines()]
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
flattened = _repair_flattened_diagram("\n".join(lines))
lines = flattened.splitlines() if flattened else []
lines = [
f" {line.strip()}"
if line.strip() in {"", ""} and not re.match(r"^\s+[│▼]\s*$", line)
else line
for line in lines
]
lines = _convert_dash_lists_to_branches(lines)
return "\n".join(lines)
def _clean_markdown_output(markdown):
if not markdown:
return ""
pieces = []
last_index = 0
for match in _FENCE_RE.finditer(markdown):
prose = markdown[last_index:match.start()]
if prose:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", prose)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
fence = match.group(0)
header, _, tail = fence.partition("\n")
body, _, _ = tail.rpartition("\n")
cleaned_body = _clean_code_block(body)
pieces.append(f"{header}\n{cleaned_body}\n```" if cleaned_body else f"{header}\n```")
last_index = match.end()
trailing = markdown[last_index:]
if trailing:
cleaned = _ESCAPED_MARKDOWN_RE.sub(r"\1", trailing)
lines = [line.strip() for line in cleaned.splitlines()]
lines = _repair_table_headers(lines)
lines = _repair_list_continuations(lines)
cleaned = "\n".join(lines)
cleaned = _collapse_blank_lines(cleaned)
if cleaned:
pieces.append(cleaned)
return "\n\n".join(piece for piece in pieces if piece)
def _convert_html_to_markdown(html):
parser = _HtmlTreeBuilder()
parser.feed(html or "")
markdown = _block_to_markdown(parser.root)
return _clean_markdown_output(markdown)
def _handle(command, args=None):
@@ -80,4 +496,8 @@ def extract_html():
def extract_markdown(selector):
"""Extract the page's main content as Markdown."""
markdown = _handle("extract.markdown", {"selector": selector})
if (markdown or "").lstrip().startswith("<"):
markdown = _convert_html_to_markdown(markdown)
else:
markdown = _clean_markdown_output(markdown or "")
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
+6 -4
View File
@@ -159,12 +159,14 @@ def group_add_tab(group, url):
@group_group.command("move")
@click.argument("group")
@click.option("--forward", is_flag=True, help="Move group one position to the right")
@click.option("--backward", is_flag=True, help="Move group one position to the left")
@click.option("-f", "--forward", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move group one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move group one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move group one position to the left")
def group_move(group, forward, backward):
"""Move a tab group forward or backward (name or ID)."""
"""Move a tab group forward/backward or right/left (name or ID)."""
if not forward and not backward:
console.print("[red]Specify --forward or --backward[/red]")
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
raise SystemExit(1)
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
if isinstance(result, dict) and not result.get("moved"):
+39 -6
View File
@@ -1,14 +1,13 @@
import click
import json
from browser_cli.client import send_command, BrowserNotConnected
from browser_cli.client import active_browser_targets, send_command, BrowserNotConnected
from rich.console import Console
console = Console()
def _handle(command, args=None):
def _handle(command, args=None, profile=None):
try:
return send_command(command, args or {})
return send_command(command, args or {}, profile=profile)
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}")
raise SystemExit(1)
@@ -17,6 +16,23 @@ def _handle(command, args=None):
raise SystemExit(1)
def _handle_multi(command, args=None, profile=None):
try:
return send_command(command, args or {}, profile=profile)
except (BrowserNotConnected, RuntimeError):
return None
def _multi_browser_targets():
root = click.get_current_context().find_root()
if root.obj.get("browser_explicit"):
return []
targets = active_browser_targets()
if len(targets) <= 1:
return []
return targets
@click.group("session")
def session_group():
"""Save and restore browser sessions."""
@@ -71,18 +87,35 @@ def session_diff(name_a, name_b):
def session_list():
"""List all saved sessions."""
from rich.table import Table
sessions = _handle("session.list")
targets = _multi_browser_targets()
show_browser = bool(targets)
if targets:
sessions = []
for target in targets:
result = _handle_multi("session.list", profile=target.profile)
if result is None:
continue
sessions.extend({**session, "browser": target.display_name} for session in result)
if not sessions:
console.print("[red]Error:[/red] Cannot resolve a browser socket automatically.")
raise SystemExit(1)
else:
sessions = _handle("session.list")
if not sessions:
console.print("[yellow]No saved sessions[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
if show_browser:
table.add_column("Browser")
table.add_column("Name")
table.add_column("Tabs", width=6)
table.add_column("Saved at")
for s in sessions:
from datetime import datetime
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
table.add_row(s["name"], str(s["tabs"]), saved)
row = [s.get("browser", "")] if show_browser else []
row.extend([s["name"], str(s["tabs"]), saved])
table.add_row(*row)
console.print(table)
+43 -3
View File
@@ -44,15 +44,18 @@ def _print_tabs(tabs: list[dict], *, show_browser: bool = False) -> None:
table.add_column("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7)
table.add_column("Muted", width=7)
table.add_column("Title")
table.add_column("URL")
for t in tabs:
active = "[green]✓[/green]" if t.get("active") else ""
muted = "[yellow]✓[/yellow]" if t.get("muted") else ""
row = [
t.get("browser", "") if show_browser else None,
str(t.get("id", "")),
str(t.get("windowId", "")),
active,
muted,
(t.get("title") or "")[:60],
(t.get("url") or "")[:80],
]
@@ -98,13 +101,15 @@ def tabs_close(tab_id, inactive, duplicates):
@tabs_group.command("move")
@click.argument("tab_id", type=int)
@click.option("--forward", is_flag=True, help="Move one position to the right")
@click.option("--backward", is_flag=True, help="Move one position to the left")
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
@click.option("-b", "--backward", "backward", is_flag=True, help="Move one position to the left")
@click.option("-r", "--right", "forward", is_flag=True, help="Move one position to the right")
@click.option("-l", "--left", "backward", is_flag=True, help="Move one position to the left")
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
@click.option("--index", type=int, default=None, help="Absolute position index in target")
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab. Use --forward/--backward for relative movement."""
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
_handle("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
@@ -120,6 +125,23 @@ def tabs_active(tab_id):
console.print(f"[green]Switched to tab {tab_id}[/green]")
@tabs_group.command("status")
@click.argument("tab_id", type=int, required=False)
def tabs_status(tab_id):
"""Show status for the active tab or a specific tab."""
tab = _handle("tabs.status", {"tabId": tab_id}) or {}
table = Table(show_header=False)
table.add_column("Field", style="bold cyan")
table.add_column("Value")
table.add_row("ID", str(tab.get("id", "")))
table.add_row("Window", str(tab.get("windowId", "")))
table.add_row("Active", "yes" if tab.get("active") else "no")
table.add_row("Muted", "yes" if tab.get("muted") else "no")
table.add_row("Title", tab.get("title") or "")
table.add_row("URL", tab.get("url") or "")
console.print(table)
@tabs_group.command("filter")
@click.argument("pattern")
def tabs_filter(pattern):
@@ -196,3 +218,21 @@ def tabs_merge_windows():
result = _handle("tabs.merge_windows")
count = result.get("moved", 0) if isinstance(result, dict) else 0
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@click.argument("tab_id", type=int, required=False)
def tabs_mute(tab_id):
"""Mute the active tab or a specific tab."""
result = _handle("tabs.mute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Muted tab {target}[/green]")
@tabs_group.command("unmute")
@click.argument("tab_id", type=int, required=False)
def tabs_unmute(tab_id):
"""Unmute the active tab or a specific tab."""
result = _handle("tabs.unmute", {"tabId": tab_id})
target = result.get("tabId", tab_id) if isinstance(result, dict) else tab_id
console.print(f"[green]Unmuted tab {target}[/green]")
+11 -5
View File
@@ -29,6 +29,7 @@ class Tab:
id: int
window_id: int
active: bool
muted: bool
title: str
url: str
group_id: int | None = None
@@ -48,6 +49,14 @@ class Tab:
"""Switch browser focus to this tab."""
self._b()._cmd("tabs.active", {"tabId": self.id})
def mute(self) -> None:
"""Mute this tab."""
self._b()._cmd("tabs.mute", {"tabId": self.id})
def unmute(self) -> None:
"""Unmute this tab."""
self._b()._cmd("tabs.unmute", {"tabId": self.id})
def reload(self) -> None:
"""Reload this tab."""
self._b()._cmd("navigate.reload", {"tabId": self.id})
@@ -87,11 +96,8 @@ class Tab:
return self._b()._cmd("tabs.html", {"tabId": self.id})
def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url*."""
# Re-uses navigate.open which opens a new tab; for in-place navigation
# we target by tabId via the focus then navigate approach. For now we
# open a new tab in the same window as a convenience.
self._b()._cmd("navigate.open", {"url": url, "background": background})
"""Navigate this tab to *url* in place."""
self._b().navigate_tab(self.id, url)
# ── Group ─────────────────────────────────────────────────────────────────────
+35 -19
View File
@@ -2,13 +2,9 @@
"""
Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the
Native Messaging protocol) and the CLI (via a Unix domain socket).
Multi-browser support: the extension sends a "hello" message on startup
with a profile alias. The host uses that alias to create a unique socket
path and registers it in a shared registry file.
Chrome launches this process when extension calls connectNative().
It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import os
@@ -18,15 +14,15 @@ import struct
import sys
import threading
import uuid
from multiprocessing.connection import Listener
from pathlib import Path
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
DEFAULT_ALIAS = "default"
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
@@ -71,8 +67,7 @@ def _registry_remove(alias: str) -> None:
def _socket_path_for(alias: str) -> str:
safe = alias.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(alias)
def _resolve_profile_alias(first_msg: dict | None) -> str:
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str):
if is_windows():
while True:
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
path = Path(sock_path)
if path.exists():
path.unlink()
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
conn, _ = sock.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn,), daemon=True).start()
threading.Thread(target=handle_cli_connection, args=(conn, None), daemon=True).start()
def handle_cli_connection(conn: socket.socket) -> None:
def handle_cli_connection(conn, listener=None) -> None:
try:
data = _recv_all(conn)
data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data:
return
cmd = json.loads(data)
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
_send_all(conn, json.dumps(result).encode("utf-8"))
response = json.dumps(result).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception as exc:
try:
_send_all(conn, json.dumps({"success": False, "error": str(exc)}).encode("utf-8"))
response = json.dumps({"success": False, "error": str(exc)}).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception:
pass
finally:
conn.close()
if listener is not None:
listener.close()
# --- Socket helpers (length-prefixed framing) ---
@@ -191,7 +206,8 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
def _cleanup(alias: str):
try:
Path(_socket_path_for(alias)).unlink(missing_ok=True)
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception:
pass
_registry_remove(alias)
@@ -215,7 +231,7 @@ def main():
PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg)
SOCKET_DIR.mkdir(mode=0o700, exist_ok=True)
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
_registry_add(alias, sock_path)
+36
View File
@@ -0,0 +1,36 @@
import os
import sys
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
def is_windows() -> bool:
return sys.platform.startswith("win")
def runtime_dir() -> Path:
if is_windows():
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / APP_NAME
return Path("/tmp") / RUNTIME_DIRNAME
def registry_path() -> Path:
return runtime_dir() / "registry.json"
def install_base_dir() -> Path:
if is_windows():
return runtime_dir()
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
def sanitize_alias(alias:str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in alias.strip())
return cleaned or DEFAULT_ALIAS
def endpoint_for_alias(alias:str) -> str:
safe = sanitize_alias(alias)
if is_windows():
return rf"\\.\pipe\browser-cli-{safe}"
return str(runtime_dir() / f"{safe}.sock")
+1 -1
View File
@@ -41,7 +41,7 @@ $CLI windows list
pause
header "3/8 · Create 'research' group and open URLs into it"
$CLI group create research
$CLI groups create research
echo ""
$CLI nav open https://example.com --group research --bg
$CLI nav open https://wikipedia.org --group research --bg
+262 -21
View File
@@ -121,6 +121,7 @@ async function dispatch(command, args) {
switch (command) {
// ── Navigation ────────────────────────────────────────────────────────
case "navigate.open": return navOpen(args);
case "navigate.to": return navTo(args);
case "navigate.reload": return navReload(args, false);
case "navigate.hard_reload": return navReload(args, true);
case "navigate.back": return navBack(args);
@@ -132,6 +133,8 @@ async function dispatch(command, args) {
case "tabs.close": return tabsClose(args);
case "tabs.move": return tabsMove(args);
case "tabs.active": return tabsActive(args);
case "tabs.active_in_window": return tabsActiveInWindow(args);
case "tabs.status": return tabsStatus(args);
case "tabs.filter": return tabsFilter(args);
case "tabs.count": return tabsCount(args);
case "tabs.query": return tabsQuery(args);
@@ -139,6 +142,8 @@ async function dispatch(command, args) {
case "tabs.dedupe": return tabsDedupe();
case "tabs.sort": return tabsSort(args);
case "tabs.merge_windows": return tabsMergeWindows();
case "tabs.mute": return tabsMute(args);
case "tabs.unmute": return tabsUnmute(args);
// ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList();
@@ -191,9 +196,11 @@ async function dispatch(command, args) {
// ── Navigation ────────────────────────────────────────────────────────────────
async function navOpen({ url, background, window: windowName, group: groupNameOrId }) {
async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
let windowId;
if (windowName) {
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]);
@@ -221,6 +228,11 @@ async function navOpen({ url, background, window: windowName, group: groupNameOr
return { id: tab.id, url: tab.url };
}
async function navTo({ tabId, url }) {
const tab = await chrome.tabs.update(tabId, { url });
return { id: tab.id, url: tab.url || url };
}
async function navReload({ tabId }, bypassCache) {
const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache });
@@ -264,15 +276,10 @@ async function tabsList() {
for (const w of windows) {
for (const t of w.tabs) {
tabs.push({
id: t.id,
windowId: t.windowId,
...tabInfo(t),
windowAlias: aliases[t.windowId] || null,
active: t.active,
pinned: t.pinned,
title: t.title,
url: t.url,
favIconUrl: t.favIconUrl,
groupId: t.groupId >= 0 ? t.groupId : null,
});
}
}
@@ -326,6 +333,20 @@ async function tabsActive({ tabId }) {
return { tabId };
}
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);
}
async function tabsStatus({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
return tabInfo(tab);
}
async function tabsFilter({ pattern }) {
const all = await chrome.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
@@ -421,8 +442,27 @@ async function tabsMergeWindows() {
return { moved };
}
async function tabsMute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "mute");
await chrome.tabs.update(tab.id, { muted: true });
return { tabId: tab.id, muted: true };
}
async function tabsUnmute({ tabId }) {
const tab = await resolveTabForDirectAction(tabId, "unmute");
await chrome.tabs.update(tab.id, { muted: false });
return { tabId: tab.id, muted: false };
}
function tabInfo(t) {
return { id: t.id, windowId: t.windowId, active: t.active, title: t.title, url: t.url };
return {
id: t.id,
windowId: t.windowId,
active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
title: t.title,
url: t.url,
};
}
// ── Groups ────────────────────────────────────────────────────────────────────
@@ -659,6 +699,32 @@ function contentDispatch(funcName, args) {
"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();
@@ -681,7 +747,7 @@ function contentDispatch(funcName, args) {
}
function escapeMarkdown(text) {
return text.replace(/([\\`*_{}\[\]()#+\-!|>])/g, "\\$1");
return text.replace(/([\\`[\]])/g, "\\$1");
}
function escapeTableCell(text) {
@@ -692,12 +758,55 @@ function contentDispatch(funcName, args) {
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("script, style, noscript, template").forEach(node => node.remove());
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);
@@ -705,10 +814,12 @@ function contentDispatch(funcName, args) {
return matched;
}
const candidates = Array.from(document.querySelectorAll("main, article, [role='main']"))
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) => (b.innerText || "").length - (a.innerText || "").length);
candidates.sort((a, b) => candidateScore(b) - candidateScore(a));
return candidates[0];
}
@@ -717,9 +828,9 @@ function contentDispatch(funcName, args) {
return escapeMarkdown(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const tag = node.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template") return "";
if (tag === "br") return "\n";
if (tag === "img") {
const src = absoluteUrl(node.getAttribute("src"), node.src);
@@ -762,6 +873,92 @@ function contentDispatch(funcName, args) {
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)
@@ -780,10 +977,16 @@ function contentDispatch(funcName, args) {
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)) {
if (!(thead || firstRowHasTh || firstRowIsBlank)) {
headers = new Array(widths).fill("");
bodyRows = normalizedRows;
}
@@ -818,7 +1021,12 @@ function contentDispatch(funcName, args) {
}
const line = collapseBlankLines(normalizeInline(content.join("")));
if (line) items.push(`${indent}${marker}${line}`);
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");
@@ -829,13 +1037,21 @@ function contentDispatch(funcName, args) {
return normalizeText(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (isNoiseElement(node)) return "";
const tag = node.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template") return "";
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 = node.innerText.replace(/\n$/, "");
const code = normalizeCodeBlock(preserveNodeText(node));
return code ? `\`\`\`\n${code}\n\`\`\`` : "";
}
if (tag === "blockquote") {
@@ -1011,9 +1227,34 @@ async function clientsRenameProfile({ alias }) {
// ── Helpers ───────────────────────────────────────────────────────────────────
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (!tab) throw new Error("No active tab found");
return tab;
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];
}
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];
}
async function resolveGroupId(nameOrId) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.5.6",
"version": "0.5.12",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "browser-cli"
version = "0.5.6"
version = "0.5.12"
description = "Control your real running browser from the terminal via a Chrome extension"
requires-python = ">=3.10"
dependencies = [
+60 -1
View File
@@ -154,6 +154,12 @@ class TestNavigation:
b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
def test_navigate_tab(self, b, mock_send):
b.navigate_tab(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
@@ -244,6 +250,18 @@ class TestTabs:
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
def test_window_active_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.window_active_tab(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None)
def test_window_active_tab_missing_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="No active tab found for window 1"):
b.window_active_tab(1)
def test_tabs_filter(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.tabs_filter("example")
@@ -488,6 +506,39 @@ class TestWindows:
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
class TestSession:
def test_session_list(self, b, mock_send):
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
result = b.session_list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[{"name": "first", "tabs": 2, "savedAt": 1712707200000}],
[{"name": "second", "tabs": 5, "savedAt": 1712707300000}],
]
result = b.session_list()
assert result == [
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"},
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"},
]
assert mock_send.call_args_list == [
call("session.list", {}, profile="default"),
call("session.list", {}, profile="work"),
]
# ── Tab model ─────────────────────────────────────────────────────────────────
class TestTabModel:
@@ -531,7 +582,15 @@ class TestTabModel:
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.open", {"url": "https://new.example.com", "background": False}, profile=None
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None
)
def test_open_background_changes_same_tab(self, tab, mock_send):
tab.open("https://new.example.com", background=True)
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None,
)
def test_unbound_raises(self):
+301 -4
View File
@@ -1,10 +1,13 @@
from pathlib import Path
from types import SimpleNamespace
import sys
from click.testing import CliRunner
from unittest.mock import patch
from browser_cli.cli import main, _project_version
from browser_cli.client import BrowserTarget
from browser_cli.commands.extract import _clean_markdown_output, _convert_html_to_markdown
def _expected_version() -> str:
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
@@ -30,26 +33,106 @@ def test_project_version_falls_back_to_installed_package_metadata():
assert _project_version() == "9.9.9"
def test_clients_rename_uses_command_level_browser_target():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
def test_clients_rename_uses_global_browser_target_when_set():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile=None)
assert "Restart the browser" not in result.output
def test_clients_rename_rejects_duplicate_alias(tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text('{"work": "/tmp/work.sock", "old-id": "/tmp/old-id.sock"}', encoding="utf-8")
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch("browser_cli.cli.send_command") as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
assert result.exit_code != 0
assert "Browser alias 'work' already exists" in result.output
send_command.assert_not_called()
def test_clients_rename_allows_same_alias_for_same_target(tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8")
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch("browser_cli.cli.send_command") as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "work", "work"])
assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="work")
def test_install_help_lists_supported_browsers():
result = CliRunner().invoke(main, ["install", "--help"])
assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = []
class FakeKey:
def __init__(self, path):
self.path = path
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER="HKCU",
KEY_WRITE=0x20006,
KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100,
REG_SZ=1,
)
def fake_create_key(root, path, reserved, access):
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
"browser_cli.cli.shutil.copy2"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
assert any("@echo off" in text for text in wrapper_writes)
def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
result = CliRunner().invoke(main, ["clients"])
@@ -183,6 +266,29 @@ def test_group_list_leaves_unnamed_group_cell_empty():
assert "grey" in result.output
def test_tabs_move_accepts_right_short_alias():
with patch("browser_cli.commands.tabs.send_command") as send_command:
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
assert result.exit_code == 0
send_command.assert_called_once_with(
"tabs.move",
{"tabId": 12, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None,
)
def test_groups_move_accepts_left_short_alias():
with patch("browser_cli.commands.groups.send_command") as send_command:
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
assert result.exit_code == 0
send_command.assert_called_once_with(
"group.move", {"group": "research", "forward": False, "backward": True}, profile=None
)
def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "windows.list"
@@ -204,6 +310,46 @@ def test_windows_list_multi_browser_shows_browser_column():
assert "work" in result.output
def test_session_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None):
assert command == "session.list"
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
with patch(
"browser_cli.commands.session.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch("browser_cli.commands.session.send_command", side_effect=fake_send_command):
result = CliRunner().invoke(main, ["session", "list"])
assert result.exit_code == 0
assert "Browser" in result.output
assert "uuid-1" in result.output
assert "work" in result.output
assert "default-session" in result.output
assert "work-session" in result.output
def test_session_list_with_explicit_browser_does_not_show_browser_column():
with patch(
"browser_cli.commands.session.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
), patch(
"browser_cli.commands.session.send_command",
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
) as send_command:
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
assert result.exit_code == 0
assert "Browser" not in result.output
send_command.assert_called_once_with("session.list", {}, profile=None)
def test_windows_open_passes_url():
with patch("browser_cli.commands.windows.send_command", return_value={"id": 7}) as send_command:
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
@@ -213,7 +359,7 @@ def test_windows_open_passes_url():
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
def test_extract_markdown_command():
with patch("browser_cli.commands.extract.send_command", return_value="# Title\n") as send_command:
with patch("browser_cli.commands.extract.send_command", return_value="# Title") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown"])
assert result.exit_code == 0
@@ -221,9 +367,160 @@ def test_extract_markdown_command():
send_command.assert_called_once_with("extract.markdown", {"selector": None})
def test_extract_markdown_command_with_selector():
with patch("browser_cli.commands.extract.send_command", return_value="## Post\n") as send_command:
with patch("browser_cli.commands.extract.send_command", return_value="## Post") as send_command:
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
assert result.exit_code == 0
assert result.output == "## Post\n"
send_command.assert_called_once_with("extract.markdown", {"selector": "article"})
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
assert _clean_markdown_output(r"hello\_world \- item") == "hello_world - item"
def test_clean_markdown_output_trims_useless_whitespace():
raw = " # Title \n\n\n paragraph with space \n next line\t \n"
assert _clean_markdown_output(raw) == "# Title\n\nparagraph with space\nnext line"
def test_clean_markdown_output_repairs_empty_table_header_rows():
raw = (
"| | | |\n"
"| --- | --- | --- |\n"
"| Bereich | Plan | Ist |\n"
"| A | B | C |\n"
)
assert _clean_markdown_output(raw) == (
"| Bereich | Plan | Ist |\n"
"| --- | --- | --- |\n"
"| A | B | C |"
)
def test_clean_markdown_output_preserves_graph_code_blocks():
raw = "```\n\nA\n\n\nB\n\n```"
assert _clean_markdown_output(raw) == "```\nA\n\n\nB\n```"
def test_clean_markdown_output_renders_code_block_list_branches():
raw = "```\nPlattformen\n- Omnifact\n- Open WebUI + Ollama\n- Le Chat\n```"
assert _clean_markdown_output(raw) == (
"```\n"
"Plattformen\n"
"├ Omnifact\n"
"├ Open WebUI + Ollama\n"
"└ Le Chat\n"
"```"
)
def test_clean_markdown_output_unflattens_graph_code_blocks():
raw = (
"```\n"
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼UpTrain(Qualitätsbewertung) │ "
"▼Langfuse(Logging / Observability) │ ▼Plattformen├ Omnifact├ Open WebUI + Ollama└ Le Chat\n"
"```"
)
assert _clean_markdown_output(raw) == (
"```\n"
"Golden Set\n"
"\n"
"\n"
"Promptfoo\n"
"(Testausführung)\n"
"\n"
"\n"
"UpTrain\n"
"(Qualitätsbewertung)\n"
"\n"
"\n"
"Langfuse\n"
"(Logging / Observability)\n"
"\n"
"\n"
"Plattformen\n"
"├ Omnifact\n"
"├ Open WebUI + Ollama\n"
"└ Le Chat\n"
"```"
)
def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
raw = (
"| | | |\n"
"| --- | --- | --- |\n"
"| Bereich | Plan | Ist |\n"
"| Eval-Stack | Testumgebung | funktionsfähig |\n\n"
"```\n"
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
"```"
)
with patch("browser_cli.commands.extract.send_command", return_value=raw):
result = CliRunner().invoke(main, ["extract", "markdown"])
assert result.exit_code == 0
assert "| Bereich | Plan | Ist |" in result.output
assert "| | | |" not in result.output
assert "Golden Set\n\n\nPromptfoo\n(Testausführung)" in result.output
assert "├ Omnifact" in result.output
assert "└ Le Chat" in result.output
def test_convert_html_to_markdown_normalizes_blank_table_header_rows():
html = """
<main>
<table>
<tr><td></td><td></td><td></td><td></td></tr>
<tr><td>Risiko</td><td>Beschreibung</td><td>Auswirkung</td><td>Gegenmaßnahme</td></tr>
<tr><td>Datenschutz</td><td>X</td><td>Y</td><td>Z</td></tr>
</table>
</main>
"""
markdown = _convert_html_to_markdown(html)
assert "| Risiko | Beschreibung | Auswirkung | Gegenmaßnahme |" in markdown
assert "| | | | |" not in markdown
def test_convert_html_to_markdown_preserves_codemirror_graph_blocks():
html = """
<main>
<h1>Teil 5 - Eval-Stack Architektur</h1>
<div class="cm-editor" data-is-code-block-view="true" contenteditable="false">
<div class="cm-line">Golden Set</div>
<div class="cm-line"> </div>
<div class="cm-line"> </div>
<div class="cm-line">Promptfoo</div>
<div class="cm-line">(Testausführung)</div>
<div class="cm-line"> </div>
<div class="cm-line"> </div>
<div class="cm-line">Plattformen</div>
<div class="cm-line">- Omnifact</div>
<div class="cm-line">- Open WebUI + Ollama</div>
<div class="cm-line">- Le Chat</div>
</div>
</main>
"""
markdown = _convert_html_to_markdown(html)
assert "```\nGolden Set\n\n\nPromptfoo" in markdown
assert "├ Omnifact" in markdown
assert "└ Le Chat" in markdown
def test_convert_html_to_markdown_indents_multiline_list_items():
html = """
<main>
<h2>2. <strong>Zielarchitektur</strong></h2>
<ul>
<li>
<p>Unternehmensdaten RAG KI-Orchestrierung <br>Local LLMs / API Modelle / Spezialmodelle</p>
</li>
</ul>
</main>
"""
markdown = _convert_html_to_markdown(html)
assert (
"- Unternehmensdaten → RAG → KI-Orchestrierung →\n"
" Local LLMs / API Modelle / Spezialmodelle"
) in markdown
+21
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
@@ -46,6 +47,13 @@ def test_display_browser_name_uses_uuid_stem_for_default():
)
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
assert _resolve_socket("work") == endpoint_for_alias("work")
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock"
active_socket.write_text("")
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
assert len(targets) == 1
assert targets[0].profile == "work"
assert targets[0].display_name == "work"
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
+14 -1
View File
@@ -18,8 +18,9 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
monkeypatch.setattr(native_host, "SOCKET_DIR", tmp_path)
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
monkeypatch.setattr(native_host, "is_windows", lambda: False)
native_host._cleanup(alias)
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = []
messages = iter([{"type": "bye"}])
+21
View File
@@ -81,3 +81,24 @@ def test_nav_open_in_background(browser):
assert not new_tab.get("active"), "background tab should not be active"
finally:
browser("tabs.close", {"tabId": new_id})
def test_nav_to_updates_existing_tab(browser):
result = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = result["id"]
try:
before_ids = {t["id"] for t in browser("tabs.list")}
updated = browser("navigate.to", {"tabId": tab_id, "url": "https://example.org"})
assert updated["id"] == tab_id
tabs = browser("tabs.list")
after_ids = {t["id"] for t in tabs}
assert after_ids == before_ids
tab = next(t for t in tabs if t["id"] == tab_id)
assert "example.org" in (tab.get("url") or "")
finally:
try:
browser("tabs.close", {"tabId": tab_id})
except Exception:
pass
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from browser_cli.platform import endpoint_for_alias, install_base_dir, registry_path, runtime_dir
def test_runtime_dir_defaults_to_tmp_on_unix(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: False)
assert runtime_dir() == Path("/tmp/.browser_cli")
def test_runtime_dir_uses_localappdata_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert runtime_dir() == tmp_path / "LocalAppData" / "browser-cli"
assert registry_path() == tmp_path / "LocalAppData" / "browser-cli" / "registry.json"
def test_endpoint_for_alias_uses_pipe_on_windows(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
assert endpoint_for_alias("work/browser") == r"\\.\pipe\browser-cli-work_browser"
def test_install_base_dir_uses_runtime_dir_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert install_base_dir() == tmp_path / "LocalAppData" / "browser-cli"
+44
View File
@@ -12,6 +12,7 @@ def test_tabs_list(browser):
assert "windowId" in first
assert "url" in first
assert "title" in first
assert "muted" in first
def test_tabs_count(browser):
@@ -44,6 +45,20 @@ def test_tabs_active_exists(browser):
assert len(active) >= 1, "Expected at least one active tab"
def test_tabs_active_in_window(browser):
active = next(t for t in browser("tabs.list") if t.get("active"))
result = browser("tabs.active_in_window", {"windowId": active["windowId"]})
assert result["id"] == active["id"]
assert result["windowId"] == active["windowId"]
def test_tabs_status(browser):
result = browser("tabs.status", {})
assert isinstance(result, dict)
assert "id" in result
assert "muted" in result
def test_tabs_html(browser, http_tab):
html = browser("tabs.html", {"tabId": http_tab["id"]})
assert isinstance(html, str)
@@ -107,3 +122,32 @@ def test_tabs_merge_windows_no_crash(browser):
result = browser("tabs.merge_windows")
assert isinstance(result, dict)
assert "moved" in result
def test_tabs_mute_and_unmute(browser, http_tab):
muted = browser("tabs.mute", {"tabId": http_tab["id"]})
assert isinstance(muted, dict)
assert muted["tabId"] == http_tab["id"]
assert muted["muted"] is True
listed = browser("tabs.list")
listed_tab = next(t for t in listed if t["id"] == http_tab["id"])
assert listed_tab["muted"] is True
unmuted = browser("tabs.unmute", {"tabId": http_tab["id"]})
assert isinstance(unmuted, dict)
assert unmuted["tabId"] == http_tab["id"]
assert unmuted["muted"] is False
listed = browser("tabs.list")
listed_tab = next(t for t in listed if t["id"] == http_tab["id"])
assert listed_tab["muted"] is False
status = browser("tabs.status", {"tabId": http_tab["id"]})
assert status["muted"] is False
def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser):
opened = browser("navigate.open", {"url": "https://example.com", "background": True})
try:
with pytest.raises(RuntimeError, match="Refusing to mute without explicit tab ID"):
browser("tabs.mute", {})
finally:
browser("tabs.close", {"tabId": opened["id"]})
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.5.6"
version = "0.5.7"
source = { editable = "." }
dependencies = [
{ name = "click" },