use full terminal columns for completion test and add native host app as a single script wraper for native host app import

This commit is contained in:
2026-05-02 01:14:28 +02:00
parent 9435dcc716
commit edafd349df
6 changed files with 74 additions and 72 deletions
+35 -29
View File
@@ -6,7 +6,6 @@ import click
import sys import sys
import os import os
import json import json
import stat
import shutil import shutil
import re import re
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
@@ -38,6 +37,20 @@ from browser_cli.registry import load_registry
console = Console() console = Console()
# Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
# patch to use a wider limit so zsh completion descriptions aren't truncated.
def _patched_group_shell_complete(self, ctx, incomplete):
from click.shell_completion import CompletionItem
results = [
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
for name, command in self.commands.items()
if not command.hidden and name.startswith(incomplete)
]
results.extend(click.Command.shell_complete(self, ctx, incomplete))
return results
click.Group.shell_complete = _patched_group_shell_complete
NATIVE_HOST_NAME = "com.browsercli.host" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg" EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
@@ -97,15 +110,25 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
raise click.ClickException(f"Browser alias '{alias}' already exists") raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_wrapper_path() -> Path: def _native_host_exe() -> Path:
base_dir = install_base_dir() base = install_base_dir()
if is_windows(): if is_windows():
return base_dir / "libexec" / "native-host.cmd" return base / "libexec" / "browser-cli-native-host.cmd"
return base_dir / "libexec" / "native-host" return base / "libexec" / "browser-cli-native-host"
def _native_host_script_path() -> Path: def _write_native_host_exe(path: Path) -> None:
return _native_host_wrapper_path().with_name("native_host.py") path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(
f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n'
)
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views(): def _windows_registry_views():
@@ -308,22 +331,8 @@ def cmd_clients_rename(target_browser, alias):
def cmd_install(browser): def cmd_install(browser):
"""Register the native messaging host and print extension load instructions.""" """Register the native messaging host and print extension load instructions."""
# Install wrapper outside PATH — the browser uses the absolute path from the host_exe = _native_host_exe()
# native messaging manifest, so only `browser-cli` needs to be on PATH. _write_native_host_exe(host_exe)
wrapper_path = _native_host_wrapper_path()
native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
if not is_windows():
native_host_script_path.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Load extension # Load extension
ext_urls = { ext_urls = {
@@ -346,14 +355,14 @@ def cmd_install(browser):
manifest = { manifest = {
"name": NATIVE_HOST_NAME, "name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host", "description": "browser-cli native messaging host",
"path": str(wrapper_path), "path": str(host_exe),
"type": "stdio", "type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/"], "allowed_origins": [f"chrome-extension://{extension_id}/"],
} }
installed = [] installed = []
if is_windows(): if is_windows():
manifest_dir = wrapper_path.parent manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True) manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json" manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
@@ -380,10 +389,7 @@ def cmd_install(browser):
console.print(f"[green]✓[/green] Registered native host: {p}") console.print(f"[green]✓[/green] Registered native host: {p}")
else: else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}") console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}") console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)") console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]") console.print("\n[green bold]✓ Installation complete![/green bold]")
-9
View File
@@ -1,9 +0,0 @@
{
"name": "com.browsercli.host",
"description": "browser-cli native messaging host",
"path": "/REPLACE_WITH_ABSOLUTE_PATH/browser-cli/libexec/native-host",
"type": "stdio",
"allowed_origins": [
"chrome-extension://REPLACE_WITH_EXTENSION_ID/"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.8.4", "version": "0.8.5",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.8.4" version = "0.8.5"
description = "Control your real running browser from the terminal via a browser extension" description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+36 -31
View File
@@ -80,22 +80,15 @@ def test_install_help_lists_supported_browsers():
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch): def test_install_windows_registers_native_host(tmp_path):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = [] writes = []
class FakeKey: class FakeKey:
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, _exc_type, _exc, _tb):
def __exit__(self, exc_type, exc, tb):
return False return False
fake_winreg = SimpleNamespace( fake_winreg = SimpleNamespace(
@@ -104,35 +97,47 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
KEY_WOW64_32KEY=0x0200, KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100, KEY_WOW64_64KEY=0x0100,
REG_SZ=1, REG_SZ=1,
CreateKeyEx=lambda _root, path, _reserved, _access: FakeKey(path),
SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append((key.path, name, value)),
) )
def fake_create_key(root, path, reserved, access): host_exe = tmp_path / "browser-cli-native-host.exe"
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch( with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path "browser_cli.cli._native_host_exe", return_value=host_exe
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch( ), patch("browser_cli.cli._write_native_host_exe"), patch(
"browser_cli.cli.shutil.copy2" "browser_cli.cli.Path.write_text"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict( ), patch.dict(sys.modules, {"winreg": fake_winreg}):
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"]) result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0 assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes) assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output def test_write_native_host_exe_unix(tmp_path):
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args] from browser_cli.cli import _write_native_host_exe
assert any("@echo off" in text for text in wrapper_writes)
host = tmp_path / "libexec" / "browser-cli-native-host"
with patch("browser_cli.cli.is_windows", return_value=False):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text()
assert content.startswith(f"#!{sys.executable}")
assert "from browser_cli.native_host import main" in content
assert host.stat().st_mode & 0o111 # executable bit set
def test_write_native_host_exe_windows(tmp_path):
from browser_cli.cli import _write_native_host_exe
host = tmp_path / "libexec" / "browser-cli-native-host.cmd"
with patch("browser_cli.cli.is_windows", return_value=True):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text(encoding="utf-8")
assert "@echo off" in content
assert "browser_cli.native_host" in content
def test_clients_exits_cleanly_when_registry_is_missing(): def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.8.4" version = "0.8.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },