Files
browser-cli/browser_cli/commands/install.py
T
daniel156161 69387c1054
Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s
fix: restore Python 3.10/3.11 support and report MCP version
- Drop backslash from f-string expression in markdown/html.py
- That syntax needs 3.12+ (PEP 701) and broke import on 3.10/3.11
- Add Python matrix 3.10/3.12/3.14/3.15 to the Gitea testing workflow
- Pass project_version() to MCPServer; serverInfo.version was empty
- Cover the reported version with an in-memory MCP client test
- Swap deprecated click.get_text_stream for sys.stdin.isatty()
- Add explicit encoding to native host shim and manifest writes
- Add explicit encoding to the registry lock file open
- Add explicit encoding to every read_text/write_text call in tests
- Bump version to 0.16.10 in pyproject.toml and extension/manifest.json
- Move .python-version to 3.15 and refresh uv.lock

Extension code is unchanged; only the manifest version string moved.

Verified with 778 tests on 3.10 through 3.15, plus a strict run using -X warn_default_encoding with EncodingWarning and DeprecationWarning as errors.
2026-10-20 12:41:25 +02:00

164 lines
6.9 KiB
Python

"""Native Messaging host installation command."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import click
from rich.console import Console
from browser_cli.constants import (
ALLOWED_EXTENSION_IDS,
CHROME_WEBSTORE_URL,
EXTENSION_ID,
FIREFOX_ADDON_URL,
FIREFOX_EXTENSION_ID,
NATIVE_HOST_DIRS,
NATIVE_HOST_NAME,
SUPPORTED_BROWSERS,
WEBSTORE_EXTENSION_ID,
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
)
from browser_cli.platform import install_base_dir, is_windows
console = Console()
def native_host_exe() -> Path:
base = install_base_dir()
if is_windows():
return base / "libexec" / "browser-cli-native-host.cmd"
return base / "libexec" / "browser-cli-native-host"
def write_native_host_exe(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native.host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n', encoding="utf-8")
path.chmod(path.stat().st_mode | 0o111)
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
@click.command("install")
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
@click.option("--dev", is_flag=True, help="Print developer instructions for loading an unpacked/temporary build instead of the public store listing.")
def cmd_install(browser, dev):
"""Register the native messaging host and print extension install instructions."""
host_exe = native_host_exe()
write_native_host_exe(host_exe)
if dev:
_print_dev_instructions(browser)
else:
_print_store_instructions(browser)
manifest = _native_host_manifest(browser, host_exe)
installed = _install_manifest(browser, host_exe, manifest)
if not installed:
console.print("[red]Failed to install native host manifest[/red]")
sys.exit(1)
for p in installed:
label = "Registered native host" if is_windows() else "Wrote native host manifest"
console.print(f"[green]✓[/green] {label}: {p}")
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
def _print_store_instructions(browser: str) -> None:
console.print("\n[bold]Step 1:[/bold] Install the extension")
if browser == "firefox":
console.print(" Open Firefox Add-ons and click [bold]Add to Firefox[/bold]:")
console.print(f" [cyan]{FIREFOX_ADDON_URL}[/cyan]")
console.print(" [dim]Firefox support is experimental; tab-group commands require browser tab group APIs.[/dim]\n")
else:
console.print(f" Open the Chrome Web Store and click [bold]Add to {browser.capitalize()}[/bold]:")
console.print(f" [cyan]{CHROME_WEBSTORE_URL}[/cyan]")
console.print(" [dim]Brave, Edge, Vivaldi and Chromium can install from the Chrome Web Store too.[/dim]")
console.print(" [dim]Developing the extension? Run 'browser-cli install <browser> --dev' for the unpacked-load steps.[/dim]\n")
def _print_dev_instructions(browser: str) -> None:
ext_url = {
"chrome": "chrome://extensions",
"chromium": "chrome://extensions",
"brave": "brave://extensions",
"edge": "edge://extensions",
"vivaldi": "vivaldi://extensions",
"firefox": "about:debugging#/runtime/this-firefox",
}[browser]
console.print("\n[bold]Step 1:[/bold] Load the unpacked extension (developer mode)")
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
if browser == "firefox":
repo_root = Path(__file__).parent.parent.parent
firefox_manifest = repo_root / "dist" / "extension-package-firefox" / "manifest.json"
console.print(" 2. Build the Firefox-compatible temporary extension:")
console.print(" [cyan]npm run package:extension:firefox[/cyan]")
console.print(" 3. Click [bold]Load Temporary Add-on...[/bold]")
console.print(f" 4. Select: [cyan]{firefox_manifest}[/cyan]")
console.print(" Do not select extension/manifest.json; Firefox currently rejects background.service_worker there.")
console.print(f" 5. Firefox extension ID is [cyan]{FIREFOX_EXTENSION_ID}[/cyan]")
console.print(" Note: Firefox support is experimental; tab-group commands require browser tab group APIs.\n")
else:
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
def _native_host_manifest(browser: str, host_exe: Path) -> dict:
base = {
"name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host",
"path": str(host_exe),
"type": "stdio",
}
if browser == "firefox":
return {**base, "allowed_extensions": [FIREFOX_EXTENSION_ID]}
return {
**base,
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
}
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
if is_windows():
manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return _register_windows_native_host(browser, manifest_path)
platform = "darwin" if sys.platform == "darwin" else "linux"
installed = []
for directory in NATIVE_HOST_DIRS[browser][platform]:
try:
directory.mkdir(parents=True, exist_ok=True)
manifest_path = directory / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed.append(manifest_path)
except Exception as e:
console.print(f"[yellow]Could not write to {directory}: {e}[/yellow]")
return installed