diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 8e3f967..2c99db3 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -17,6 +17,7 @@ from browser_cli.commands.windows import windows_group from browser_cli.commands.dom import dom_group from browser_cli.commands.extract import extract_group from browser_cli.commands.session import session_group +from browser_cli.commands.search import search_group from browser_cli.client import send_command, BrowserNotConnected console = Console() @@ -52,6 +53,7 @@ main.add_command(windows_group) main.add_command(dom_group) main.add_command(extract_group) main.add_command(session_group) +main.add_command(search_group) # ── clients ──────────────────────────────────────────────────────────────────── @@ -118,20 +120,12 @@ def cmd_rename_profile(alias): def cmd_install(browser): """Register the native messaging host and print extension load instructions.""" - # Find the venv entry point for the native host (stable regardless of project location) - venv_script = Path(sys.executable).parent / "browser-cli-native-host" - if not venv_script.exists(): - console.print(f"[red]Cannot find browser-cli-native-host in venv ({venv_script})[/red]") - console.print(" Run [cyan]uv sync[/cyan] first to install entry points.") - sys.exit(1) - - # Install wrapper to ~/.local/bin so the manifest path never changes - local_bin = Path.home() / ".local" / "bin" - local_bin.mkdir(parents=True, exist_ok=True) - wrapper_path = local_bin / "browser-cli-native-host" - wrapper_content = f"""#!/bin/sh -exec "{venv_script}" "$@" -""" + # Install wrapper outside PATH — Chrome uses the absolute path from the manifest, + # so it doesn't need to be a shell command. + share_dir = Path.home() / ".local" / "share" / "browser-cli" + share_dir.mkdir(parents=True, exist_ok=True) + wrapper_path = share_dir / "native-host" + wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" -m browser_cli.native_host "$@"\n' wrapper_path.write_text(wrapper_content) wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) @@ -181,6 +175,15 @@ exec "{venv_script}" "$@" console.print(" After restarting Chrome, try: [cyan]browser-cli tabs list[/cyan]") +# ── native-host (hidden, called by Chrome via native messaging) ──────────────── + +@main.command("native-host", hidden=True) +def cmd_native_host(): + """Native messaging host — called by Chrome, not for direct use.""" + from browser_cli.native_host import main as _main + _main() + + # ── completion ───────────────────────────────────────────────────────────────── @main.command("completion") diff --git a/browser_cli/commands/search.py b/browser_cli/commands/search.py new file mode 100644 index 0000000..40fb08f --- /dev/null +++ b/browser_cli/commands/search.py @@ -0,0 +1,90 @@ +import click +from urllib.parse import quote_plus +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console + +console = Console() + +ENGINES = { + "google": "https://www.google.com/search?q={query}", + "brave": "https://search.brave.com/search?q={query}", + "duckduckgo": "https://duckduckgo.com/?q={query}", + "ddg": "https://duckduckgo.com/?q={query}", + "youtube": "https://www.youtube.com/results?search_query={query}", + "yt": "https://www.youtube.com/results?search_query={query}", + "spotify": "https://open.spotify.com/search/{query}", + "amazon": "https://www.amazon.com/s?k={query}", + "ecosia": "https://www.ecosia.org/search?q={query}", + "furaffinity": "https://www.furaffinity.net/search/?q={query}", + "fa": "https://www.furaffinity.net/search/?q={query}", + "bing": "https://www.bing.com/search?q={query}", + "github": "https://github.com/search?q={query}", + "wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}", + "wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}", + "reddit": "https://www.reddit.com/search/?q={query}", + "stackoverflow": "https://stackoverflow.com/search?q={query}", + "so": "https://stackoverflow.com/search?q={query}", +} + +_DISPLAY_NAMES = { + "google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo", + "ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube", + "spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia", + "furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing", + "github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia", + "reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow", +} + +_SUBCOMMANDS = [ + ("google", "Search with Google."), + ("brave", "Search with Brave Search."), + ("duckduckgo", "Search with DuckDuckGo."), + ("ddg", "Search with DuckDuckGo (alias for duckduckgo)."), + ("youtube", "Search YouTube videos."), + ("yt", "Search YouTube (alias for youtube)."), + ("spotify", "Search Spotify."), + ("amazon", "Search Amazon."), + ("ecosia", "Search with Ecosia."), + ("furaffinity", "Search FurAffinity."), + ("fa", "Search FurAffinity (alias for furaffinity)."), + ("bing", "Search with Bing."), + ("github", "Search GitHub."), + ("wikipedia", "Search Wikipedia."), + ("wiki", "Search Wikipedia (alias for wikipedia)."), + ("reddit", "Search Reddit."), + ("stackoverflow", "Search Stack Overflow."), + ("so", "Search Stack Overflow (alias for stackoverflow)."), +] + + +@click.group("search") +def search_group(): + """Search the web — open a query in a search engine.""" + + +def _build_command(engine_key: str, help_text: str) -> click.Command: + @click.command(engine_key, help=help_text) + @click.argument("query", nargs=-1, required=True) + @click.option("--bg", is_flag=True, help="Open in background (no focus)") + @click.option("--window", "window", default=None, help="Open in named window") + @click.option("--group", "group", default=None, help="Open in tab group (name or ID)") + def _cmd(query, bg, window, group): + terms = " ".join(query) + url = ENGINES[engine_key].format(query=quote_plus(terms)) + try: + send_command("navigate.open", {"url": url, "background": bg, "window": window, "group": group}) + except BrowserNotConnected as e: + console.print(f"[red]Error:[/red] {e}") + raise SystemExit(1) + except RuntimeError as e: + console.print(f"[red]Browser error:[/red] {e}") + raise SystemExit(1) + suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "") + display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize()) + console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}") + + return _cmd + + +for _name, _help in _SUBCOMMANDS: + search_group.add_command(_build_command(_name, _help))