From edafd349df6ed88a97fc08c3beb219525727f9da Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Sat, 2 May 2026 01:14:28 +0200 Subject: [PATCH] use full terminal columns for completion test and add native host app as a single script wraper for native host app import --- browser_cli/cli.py | 64 +++++++++++++++++++++----------------- com.browsercli.host.json | 9 ------ extension/manifest.json | 2 +- pyproject.toml | 2 +- tests/test_cli.py | 67 +++++++++++++++++++++------------------- uv.lock | 2 +- 6 files changed, 74 insertions(+), 72 deletions(-) delete mode 100644 com.browsercli.host.json diff --git a/browser_cli/cli.py b/browser_cli/cli.py index a4b9a33..18250b4 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -6,7 +6,6 @@ import click import sys import os import json -import stat import shutil import re from importlib.metadata import PackageNotFoundError, version as package_version @@ -38,6 +37,20 @@ from browser_cli.registry import load_registry 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" 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") -def _native_host_wrapper_path() -> Path: - base_dir = install_base_dir() +def _native_host_exe() -> Path: + base = install_base_dir() if is_windows(): - return base_dir / "libexec" / "native-host.cmd" - return base_dir / "libexec" / "native-host" + return base / "libexec" / "browser-cli-native-host.cmd" + return base / "libexec" / "browser-cli-native-host" -def _native_host_script_path() -> Path: - return _native_host_wrapper_path().with_name("native_host.py") +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' + ) + path.chmod(path.stat().st_mode | 0o111) def _windows_registry_views(): @@ -308,22 +331,8 @@ def cmd_clients_rename(target_browser, alias): def cmd_install(browser): """Register the native messaging host and print extension load instructions.""" - # Install wrapper outside PATH — the browser uses the absolute path from the - # native messaging manifest, so only `browser-cli` needs to be on PATH. - 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") + host_exe = _native_host_exe() + _write_native_host_exe(host_exe) # Load extension ext_urls = { @@ -346,14 +355,14 @@ def cmd_install(browser): manifest = { "name": NATIVE_HOST_NAME, "description": "browser-cli native messaging host", - "path": str(wrapper_path), + "path": str(host_exe), "type": "stdio", "allowed_origins": [f"chrome-extension://{extension_id}/"], } installed = [] if is_windows(): - manifest_dir = wrapper_path.parent + 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") @@ -380,10 +389,7 @@ def cmd_install(browser): 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"[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]") diff --git a/com.browsercli.host.json b/com.browsercli.host.json deleted file mode 100644 index ed5ac9d..0000000 --- a/com.browsercli.host.json +++ /dev/null @@ -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/" - ] -} diff --git a/extension/manifest.json b/extension/manifest.json index 54863fa..224e0d6 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.8.4", + "version": "0.8.5", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index c189b86..14e1bcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.8.4" +version = "0.8.5" description = "Control your real running browser from the terminal via a browser extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 35c9ae2..3b11e73 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,22 +80,15 @@ def test_install_help_lists_supported_browsers(): 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") +def test_install_windows_registers_native_host(tmp_path): writes = [] class FakeKey: def __init__(self, path): self.path = path - def __enter__(self): return self - - def __exit__(self, exc_type, exc, tb): + def __exit__(self, _exc_type, _exc, _tb): return False fake_winreg = SimpleNamespace( @@ -104,35 +97,47 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch): KEY_WOW64_32KEY=0x0200, KEY_WOW64_64KEY=0x0100, 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): - 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)) - + host_exe = tmp_path / "browser-cli-native-host.exe" 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") + "browser_cli.cli._native_host_exe", return_value=host_exe + ), patch("browser_cli.cli._write_native_host_exe"), patch( + "browser_cli.cli.Path.write_text" + ), patch.dict(sys.modules, {"winreg": fake_winreg}): 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_write_native_host_exe_unix(tmp_path): + from browser_cli.cli import _write_native_host_exe + + 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(): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( diff --git a/uv.lock b/uv.lock index ddd97d0..aebf928 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.8.4" +version = "0.8.5" source = { editable = "." } dependencies = [ { name = "click" },