Compare commits

...

7 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
20 changed files with 570 additions and 90 deletions
+9 -5
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. 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 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) 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. 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). 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. 4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI. 5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
@@ -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. 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/ ├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point │ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── cli.py # Click CLI 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 │ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser │ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/ │ └── commands/
@@ -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 close 42 # ungroup the group
browser-cli groups move research --forward # move group right 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 --backward # move group left
browser-cli groups move 42 --left # same as --backward
browser-cli groups move 42 -l # short left alias
``` ```
### Windows ### Windows
+29
View File
@@ -77,6 +77,7 @@ class BrowserCLI:
id=data["id"], id=data["id"],
window_id=data.get("windowId", 0), window_id=data.get("windowId", 0),
active=data.get("active", False), active=data.get("active", False),
muted=data.get("muted", False),
title=data.get("title") or "", title=data.get("title") or "",
url=data.get("url") or "", url=data.get("url") or "",
group_id=data.get("groupId") or None, group_id=data.get("groupId") or None,
@@ -117,6 +118,10 @@ class BrowserCLI:
def focus_url(self, pattern: str) -> None: def focus_url(self, pattern: str) -> None:
self._cmd("navigate.focus", {"pattern": pattern}) 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 ──────────────────────────────────────────────────────────── # ── Search ────────────────────────────────────────────────────────────
def search( def search(
@@ -168,6 +173,30 @@ class BrowserCLI:
"""Switch browser focus to a tab by ID.""" """Switch browser focus to a tab by ID."""
self._cmd("tabs.active", {"tabId": tab_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]: 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.""" """Return tabs filtered by pattern or a Python callable."""
if isinstance(pattern_or_filter, str): if isinstance(pattern_or_filter, str):
+54 -7
View File
@@ -28,6 +28,7 @@ from browser_cli.client import (
active_browser_targets, active_browser_targets,
display_browser_name, display_browser_name,
) )
from browser_cli.platform import install_base_dir, is_windows
console = Console() console = Console()
@@ -56,6 +57,14 @@ 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: def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser: if target_browser:
@@ -82,10 +91,9 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
def _native_host_wrapper_path() -> Path: def _native_host_wrapper_path() -> Path:
if sys.platform == "darwin": base_dir = install_base_dir()
base_dir = Path.home() / "Library" / "Application Support" / "browser-cli" if is_windows():
else: return base_dir / "libexec" / "native-host.cmd"
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli"
return base_dir / "libexec" / "native-host" return base_dir / "libexec" / "native-host"
@@ -93,6 +101,30 @@ def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py") 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: def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try: try:
@@ -235,12 +267,16 @@ def cmd_install(browser):
native_host_script_path = _native_host_script_path() native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True) wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path) 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.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH 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_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content) wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) 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 # Ask for extension ID
ext_urls = { ext_urls = {
@@ -269,11 +305,17 @@ def cmd_install(browser):
"allowed_origins": [f"chrome-extension://{extension_id}/"], "allowed_origins": [f"chrome-extension://{extension_id}/"],
} }
# Write to OS native messaging dirs installed = []
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" platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform] dirs = NATIVE_HOST_DIRS[browser][platform]
installed = []
for d in dirs: for d in dirs:
try: try:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -288,11 +330,16 @@ def cmd_install(browser):
sys.exit(1) sys.exit(1)
for p in installed: for p in installed:
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] 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 script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_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("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]") console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
+18 -10
View File
@@ -1,11 +1,11 @@
""" """
Unix socket client sends commands to the native host relay socket. Local IPC client sends commands to native host relay endpoint.
Used by both the CLI and the public Python API. Used by both CLI and public Python API.
Profile selection order: Profile selection order:
1. Explicit `profile` argument to send_command() 1. Explicit `profile` argument to send_command()
2. BROWSER_CLI_PROFILE environment variable 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 4. Otherwise, no browser can be resolved automatically
""" """
import json import json
@@ -14,11 +14,13 @@ import socket
import struct import struct
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
SOCKET_DIR = Path("/tmp/.browser_cli") from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
REGISTRY_PATH = SOCKET_DIR / "registry.json"
REGISTRY_PATH = registry_path()
class BrowserNotConnected(Exception): class BrowserNotConnected(Exception):
@@ -32,8 +34,10 @@ class BrowserTarget:
socket_path: str socket_path: str
def _active_sockets(reg: dict) -> dict: def _active_endpoints(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk.""" """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()} 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 []
return [ return [
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_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] return reg[target]
except Exception: except Exception:
pass pass
safe = target.replace(" ", "_").replace("/", "_") return endpoint_for_alias(target)
return str(SOCKET_DIR / f"{safe}.sock")
# Auto-detect: error when multiple browser instances are active # Auto-detect: error when multiple browser instances are active
try: try:
@@ -107,6 +110,11 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
framed = struct.pack("<I", len(payload)) + payload framed = struct.pack("<I", len(payload)) + payload
try: try:
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: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path) sock.connect(sock_path)
sock.sendall(framed) sock.sendall(framed)
+6 -4
View File
@@ -159,12 +159,14 @@ def group_add_tab(group, url):
@group_group.command("move") @group_group.command("move")
@click.argument("group") @click.argument("group")
@click.option("--forward", is_flag=True, help="Move group one position to the right") @click.option("-f", "--forward", "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("-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): 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: 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) raise SystemExit(1)
result = _handle("group.move", {"group": group, "forward": forward, "backward": backward}) result = _handle("group.move", {"group": group, "forward": forward, "backward": backward})
if isinstance(result, dict) and not result.get("moved"): if isinstance(result, dict) and not result.get("moved"):
+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("ID", style="dim", no_wrap=True)
table.add_column("Window", no_wrap=True) table.add_column("Window", no_wrap=True)
table.add_column("Active", width=7) table.add_column("Active", width=7)
table.add_column("Muted", width=7)
table.add_column("Title") table.add_column("Title")
table.add_column("URL") table.add_column("URL")
for t in tabs: for t in tabs:
active = "[green]✓[/green]" if t.get("active") else "" active = "[green]✓[/green]" if t.get("active") else ""
muted = "[yellow]✓[/yellow]" if t.get("muted") else ""
row = [ row = [
t.get("browser", "") if show_browser else None, t.get("browser", "") if show_browser else None,
str(t.get("id", "")), str(t.get("id", "")),
str(t.get("windowId", "")), str(t.get("windowId", "")),
active, active,
muted,
(t.get("title") or "")[:60], (t.get("title") or "")[:60],
(t.get("url") or "")[:80], (t.get("url") or "")[:80],
] ]
@@ -98,13 +101,15 @@ def tabs_close(tab_id, inactive, duplicates):
@tabs_group.command("move") @tabs_group.command("move")
@click.argument("tab_id", type=int) @click.argument("tab_id", type=int)
@click.option("--forward", is_flag=True, help="Move one position to the right") @click.option("-f", "--forward", "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("-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("--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("--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") @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): 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", { _handle("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward, "tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index, "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]") 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") @tabs_group.command("filter")
@click.argument("pattern") @click.argument("pattern")
def tabs_filter(pattern): def tabs_filter(pattern):
@@ -196,3 +218,21 @@ def tabs_merge_windows():
result = _handle("tabs.merge_windows") result = _handle("tabs.merge_windows")
count = result.get("moved", 0) if isinstance(result, dict) else 0 count = result.get("moved", 0) if isinstance(result, dict) else 0
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
@tabs_group.command("mute")
@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 id: int
window_id: int window_id: int
active: bool active: bool
muted: bool
title: str title: str
url: str url: str
group_id: int | None = None group_id: int | None = None
@@ -48,6 +49,14 @@ class Tab:
"""Switch browser focus to this tab.""" """Switch browser focus to this tab."""
self._b()._cmd("tabs.active", {"tabId": self.id}) 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: def reload(self) -> None:
"""Reload this tab.""" """Reload this tab."""
self._b()._cmd("navigate.reload", {"tabId": self.id}) self._b()._cmd("navigate.reload", {"tabId": self.id})
@@ -87,11 +96,8 @@ class Tab:
return self._b()._cmd("tabs.html", {"tabId": self.id}) return self._b()._cmd("tabs.html", {"tabId": self.id})
def open(self, url: str, *, background: bool = False) -> None: def open(self, url: str, *, background: bool = False) -> None:
"""Navigate this tab to *url*.""" """Navigate this tab to *url* in place."""
# Re-uses navigate.open which opens a new tab; for in-place navigation self._b().navigate_tab(self.id, url)
# 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})
# ── Group ───────────────────────────────────────────────────────────────────── # ── Group ─────────────────────────────────────────────────────────────────────
+34 -18
View File
@@ -2,13 +2,9 @@
""" """
Native Messaging Host for browser-cli. Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative(). Chrome launches this process when extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the It relays messages between extension (stdin/stdout Native Messaging protocol)
Native Messaging protocol) and the CLI (via a Unix domain socket). and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
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.
""" """
import json import json
import os import os
@@ -18,15 +14,15 @@ import struct
import sys import sys
import threading import threading
import uuid import uuid
from multiprocessing.connection import Listener
from pathlib import Path from pathlib import Path
SOCKET_DIR = Path("/tmp/.browser_cli") from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
REGISTRY_PATH = SOCKET_DIR / "registry.json"
DEFAULT_ALIAS = "default"
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()
REGISTRY_PATH = registry_path()
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) --- # --- 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: def _socket_path_for(alias: str) -> str:
safe = alias.replace(" ", "_").replace("/", "_") return endpoint_for_alias(alias)
return str(SOCKET_DIR / f"{safe}.sock")
def _resolve_profile_alias(first_msg: dict | None) -> str: def _resolve_profile_alias(first_msg: dict | None) -> str:
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections --- # --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str): 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) path = Path(sock_path)
if path.exists(): if path.exists():
path.unlink() path.unlink()
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
conn, _ = sock.accept() conn, _ = sock.accept()
except OSError: except OSError:
break 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: try:
data = _recv_all(conn) data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data: if not data:
return return
cmd = json.loads(data) cmd = json.loads(data)
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK: with PENDING_LOCK:
PENDING.pop(msg_id, None) 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: except Exception as exc:
try: 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: except Exception:
pass pass
finally: finally:
conn.close() conn.close()
if listener is not None:
listener.close()
# --- Socket helpers (length-prefixed framing) --- # --- Socket helpers (length-prefixed framing) ---
@@ -191,6 +206,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
def _cleanup(alias: str): def _cleanup(alias: str):
try: try:
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True) Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception: except Exception:
pass pass
@@ -215,7 +231,7 @@ def main():
PENDING[msg_id] = q PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg) 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) sock_path = _socket_path_for(alias)
_registry_add(alias, sock_path) _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")
+62 -9
View File
@@ -121,6 +121,7 @@ async function dispatch(command, args) {
switch (command) { switch (command) {
// ── Navigation ──────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────
case "navigate.open": return navOpen(args); case "navigate.open": return navOpen(args);
case "navigate.to": return navTo(args);
case "navigate.reload": return navReload(args, false); case "navigate.reload": return navReload(args, false);
case "navigate.hard_reload": return navReload(args, true); case "navigate.hard_reload": return navReload(args, true);
case "navigate.back": return navBack(args); case "navigate.back": return navBack(args);
@@ -132,6 +133,8 @@ async function dispatch(command, args) {
case "tabs.close": return tabsClose(args); case "tabs.close": return tabsClose(args);
case "tabs.move": return tabsMove(args); case "tabs.move": return tabsMove(args);
case "tabs.active": return tabsActive(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.filter": return tabsFilter(args);
case "tabs.count": return tabsCount(args); case "tabs.count": return tabsCount(args);
case "tabs.query": return tabsQuery(args); case "tabs.query": return tabsQuery(args);
@@ -139,6 +142,8 @@ async function dispatch(command, args) {
case "tabs.dedupe": return tabsDedupe(); case "tabs.dedupe": return tabsDedupe();
case "tabs.sort": return tabsSort(args); case "tabs.sort": return tabsSort(args);
case "tabs.merge_windows": return tabsMergeWindows(); case "tabs.merge_windows": return tabsMergeWindows();
case "tabs.mute": return tabsMute(args);
case "tabs.unmute": return tabsUnmute(args);
// ── Groups ──────────────────────────────────────────────────────────── // ── Groups ────────────────────────────────────────────────────────────
case "group.list": return groupList(); case "group.list": return groupList();
@@ -191,9 +196,11 @@ async function dispatch(command, args) {
// ── Navigation ──────────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────────
async function navOpen({ url, background, window: windowName, group: groupNameOrId }) { async function navOpen({ url, background, window: windowName, windowId: explicitWindowId, group: groupNameOrId }) {
let windowId; let windowId;
if (windowName) { if (explicitWindowId != null) {
windowId = explicitWindowId;
} else if (windowName) {
const aliases = await getAliases(); const aliases = await getAliases();
const entry = Object.entries(aliases).find(([, v]) => v === windowName); const entry = Object.entries(aliases).find(([, v]) => v === windowName);
if (entry) windowId = parseInt(entry[0]); 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 }; 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) { async function navReload({ tabId }, bypassCache) {
const tab = tabId ? { id: tabId } : await getActiveTab(); const tab = tabId ? { id: tabId } : await getActiveTab();
await chrome.tabs.reload(tab.id, { bypassCache }); await chrome.tabs.reload(tab.id, { bypassCache });
@@ -264,15 +276,10 @@ async function tabsList() {
for (const w of windows) { for (const w of windows) {
for (const t of w.tabs) { for (const t of w.tabs) {
tabs.push({ tabs.push({
id: t.id, ...tabInfo(t),
windowId: t.windowId,
windowAlias: aliases[t.windowId] || null, windowAlias: aliases[t.windowId] || null,
active: t.active,
pinned: t.pinned, pinned: t.pinned,
title: t.title,
url: t.url,
favIconUrl: t.favIconUrl, favIconUrl: t.favIconUrl,
groupId: t.groupId >= 0 ? t.groupId : null,
}); });
} }
} }
@@ -326,6 +333,20 @@ async function tabsActive({ tabId }) {
return { 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 }) { async function tabsFilter({ pattern }) {
const all = await chrome.tabs.query({}); const all = await chrome.tabs.query({});
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
@@ -421,8 +442,27 @@ async function tabsMergeWindows() {
return { moved }; 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) { 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 ──────────────────────────────────────────────────────────────────── // ── Groups ────────────────────────────────────────────────────────────────────
@@ -1204,6 +1244,19 @@ async function getActiveTab() {
|| activeTabs[0]; || 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) { async function resolveGroupId(nameOrId) {
const asInt = parseInt(nameOrId); const asInt = parseInt(nameOrId);
if (!isNaN(asInt)) return asInt; if (!isNaN(asInt)) return asInt;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.5.7", "version": "0.5.12",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.5.7" version = "0.5.12"
description = "Control your real running browser from the terminal via a Chrome extension" description = "Control your real running browser from the terminal via a Chrome extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+27 -1
View File
@@ -154,6 +154,12 @@ class TestNavigation:
b.focus_url("github.com") b.focus_url("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None) mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
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): def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload() b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave") mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
@@ -244,6 +250,18 @@ class TestTabs:
b.tabs_active(10) b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None) mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
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): def test_tabs_filter(self, b, mock_send):
mock_send.return_value = [TAB_DATA] mock_send.return_value = [TAB_DATA]
tabs = b.tabs_filter("example") tabs = b.tabs_filter("example")
@@ -564,7 +582,15 @@ class TestTabModel:
def test_open(self, tab, mock_send): def test_open(self, tab, mock_send):
tab.open("https://new.example.com") tab.open("https://new.example.com")
mock_send.assert_called_once_with( 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): def test_unbound_raises(self):
+86 -2
View File
@@ -1,4 +1,6 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import sys
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch from unittest.mock import patch
@@ -31,14 +33,18 @@ def test_project_version_falls_back_to_installed_package_metadata():
assert _project_version() == "9.9.9" assert _project_version() == "9.9.9"
def test_clients_rename_uses_command_level_browser_target(): 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"]) result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
assert result.exit_code == 0 assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id") send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
def test_clients_rename_uses_global_browser_target_when_set(): 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"]) result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -72,6 +78,61 @@ def test_install_help_lists_supported_browsers():
assert result.exit_code == 0 assert result.exit_code == 0
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):
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(): 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")):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
@@ -205,6 +266,29 @@ def test_group_list_leaves_unnamed_group_cell_empty():
assert "grey" in result.output 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 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"
+21
View File
@@ -4,6 +4,7 @@ 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, _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): def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) 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): def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock" active_socket = tmp_path / "work.sock"
active_socket.write_text("") active_socket.write_text("")
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].profile == "work" assert targets[0].profile == "work"
assert targets[0].display_name == "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 = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")})) 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, "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) native_host._cleanup(alias)
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"] 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): def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = [] cleaned = []
messages = iter([{"type": "bye"}]) 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" assert not new_tab.get("active"), "background tab should not be active"
finally: finally:
browser("tabs.close", {"tabId": new_id}) 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 "windowId" in first
assert "url" in first assert "url" in first
assert "title" in first assert "title" in first
assert "muted" in first
def test_tabs_count(browser): 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" 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): def test_tabs_html(browser, http_tab):
html = browser("tabs.html", {"tabId": http_tab["id"]}) html = browser("tabs.html", {"tabId": http_tab["id"]})
assert isinstance(html, str) assert isinstance(html, str)
@@ -107,3 +122,32 @@ def test_tabs_merge_windows_no_crash(browser):
result = browser("tabs.merge_windows") result = browser("tabs.merge_windows")
assert isinstance(result, dict) assert isinstance(result, dict)
assert "moved" in result 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]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.5.6" version = "0.5.7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },