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:
+35
-29
@@ -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]")
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user