commit 178b7bf7a2a05dc8823250cfc1499106ddf0ffc7 Author: Daniel Dolezal Date: Wed Apr 8 21:17:59 2026 +0200 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e689877 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ + +*.pyc diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..14fdc0f --- /dev/null +++ b/README.md @@ -0,0 +1,334 @@ +# browser-cli + +Control your real, running browser from the terminal or a Python script — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands. + +--- + +## What it does + +You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for. + +It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local socket. + +--- + +## How it works + +``` +terminal / python script + │ + │ Unix socket (/tmp/browser-cli.sock) + ▼ + Native Messaging Host (Python process, launched by the browser) + │ + │ Native Messaging Protocol (stdin/stdout, 4-byte length prefix + JSON) + ▼ + Chrome Extension (background service worker) + │ + │ chrome.* APIs + ▼ + Your running browser +``` + +1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup. +2. The browser launches the native host Python process (registered in the OS). +3. The native host opens a Unix socket at `/tmp/browser-cli.sock`. +4. CLI commands connect to that socket, send a JSON command, and wait for the result. +5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI. + +No server needs to be running beforehand. The browser manages the native host's lifecycle. + +**Message format** + +Every command is a JSON object: +```json +{ "id": "uuid", "command": "tabs.list", "args": {} } +``` +Every response: +```json +{ "id": "uuid", "success": true, "data": [...] } +``` + +--- + +## Installation + +**Requirements:** Python 3.10+, [uv](https://github.com/astral-sh/uv), Chrome or Brave + +```sh +git clone +cd browser-cli +uv sync +uv run browser-cli install brave # or: chrome, chromium +``` + +The `install` command will: +1. Ask you to load the `extension/` folder as an unpacked extension in your browser (`brave://extensions` → Developer mode → Load unpacked) +2. Ask you to paste the extension ID shown on the extension card +3. Write the native messaging manifest to your OS so the browser can find the host +4. Create an executable wrapper script for the native host + +After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup. + +--- + +## Project structure + +``` +browser-cli/ +├── browser_cli/ +│ ├── __init__.py # Python API — BrowserCLI class +│ ├── native_host.py # Native messaging host (launched by browser) +│ ├── client.py # Unix socket client (used by CLI and Python API) +│ ├── cli.py # Click CLI entry point +│ └── commands/ +│ ├── navigate.py # open, reload, back, forward, focus +│ ├── tabs.py # tab management +│ ├── groups.py # tab group management +│ ├── windows.py # window management +│ ├── dom.py # DOM querying and interaction +│ ├── extract.py # content extraction +│ └── session.py # session save/load +├── extension/ +│ ├── manifest.json # MV3 extension manifest +│ ├── background.js # Service worker — receives commands, calls chrome.* APIs +│ └── content.js # Placeholder for future persistent content script +├── examples/ +│ ├── demo.py # Python API walkthrough +│ └── demo.sh # Bash CLI walkthrough +└── pyproject.toml +``` + +--- + +## CLI reference + +All commands are run with `uv run browser-cli `. + +### Navigation + +```sh +# Open a URL +browser-cli open https://example.com +browser-cli open https://example.com --bg # background, no focus +browser-cli open https://example.com --window work # into a named window +browser-cli open https://example.com --group research # into a tab group (name or ID) + +# Reload +browser-cli reload # reload active tab +browser-cli reload 1234 # reload tab by ID +browser-cli hard-reload # bypass cache + +# Navigate history +browser-cli back +browser-cli forward 1234 # forward in specific tab + +# Jump to a tab by URL pattern +browser-cli focus github # focuses first tab whose URL contains "github" +``` + +### Tabs + +```sh +browser-cli tabs list # list all open tabs (all windows) +browser-cli tabs count # count all tabs +browser-cli tabs count youtube # count tabs matching URL pattern +browser-cli tabs filter youtube # list tabs matching URL pattern +browser-cli tabs query "pull request" # search tabs by URL or title + +browser-cli tabs active 1234 # switch browser focus to tab +browser-cli tabs html # print full HTML of active tab +browser-cli tabs html 1234 # print HTML of specific tab + +browser-cli tabs close 1234 # close specific tab +browser-cli tabs close --inactive # close all inactive tabs +browser-cli tabs close --duplicates # close duplicate URLs (keep first) +browser-cli tabs dedupe # same as close --duplicates + +browser-cli tabs move 1234 --window 2 # move tab to another window +browser-cli tabs move 1234 --group 42 # move tab into a group + +browser-cli tabs sort --by domain # sort tabs within each window +browser-cli tabs sort --by title +browser-cli tabs sort --by time + +browser-cli tabs merge-windows # pull all tabs into the current window +``` + +### Tab groups + +```sh +browser-cli group list # list all tab groups +browser-cli group count # count groups +browser-cli group query "work" # search groups by name +browser-cli group tabs 42 # list tabs inside group ID 42 + +browser-cli group create "research" # create a new group +browser-cli group add-tab research # open a blank tab in the group +browser-cli group add-tab research https://example.com # open URL in the group +browser-cli group add-tab 42 https://example.com # by group ID + +browser-cli group close 42 # ungroup the group +``` + +### Windows + +```sh +browser-cli windows list # list all windows +browser-cli windows open # open a new window +browser-cli windows rename 1 "work" # give a window a local alias +browser-cli windows close 1 # close a window +``` + +### DOM + +These commands run on the **active tab**. The tab must be on a regular `http://` or `https://` page — not a browser internal page like `brave://newtab`. + +```sh +browser-cli dom query "h1" # return elements matching CSS selector +browser-cli dom text "h1" # get text content of matching elements +browser-cli dom attr "a" href # get attribute value from elements +browser-cli dom exists ".cookie-banner" # exits 0 if found, 1 if not +browser-cli dom click ".accept-button" # click an element +browser-cli dom type "#search" "hello" # type text into an input +``` + +### Extract + +```sh +browser-cli extract links # all links on the page +browser-cli extract images # all tags (src + alt) +browser-cli extract text # all visible text (innerText) +browser-cli extract json "#data" # parse JSON inside a CSS selector +``` + +### Sessions + +A session is a snapshot of all open tab URLs, stored inside the extension via `chrome.storage.local`. Sessions survive browser restarts but are lost if the extension is uninstalled or extension data is cleared. + +```sh +browser-cli session save before-meeting # save current tabs as a named session +browser-cli session load before-meeting # reopen all saved tabs +browser-cli session list # list all saved sessions (name, tab count, date) +browser-cli session remove before-meeting # delete a saved session +browser-cli session diff session-a session-b # show which URLs were added / removed +browser-cli session auto-save on # auto-save after every tab change +browser-cli session auto-save off +``` + +### Misc + +```sh +browser-cli clients # show connected browser info +browser-cli install brave # (re)register the native host +``` + +--- + +## Python API + +```python +from browser_cli import BrowserCLI + +b = BrowserCLI() +``` + +Every CLI command has a corresponding method. The call blocks until the browser responds and returns the data directly as a Python object. + +```python +# Navigation +b.open("https://example.com") +b.open("https://example.com", background=True) +b.open("https://example.com", window="work") +b.reload() +b.hard_reload() +b.back() +b.forward(tab_id=1234) +b.focus_url("github") + +# Tabs +tabs = b.tabs_list() # list of dicts: id, windowId, title, url, active, ... +b.tabs_active(1234) +b.tabs_close(1234) +b.tabs_close_inactive() +b.tabs_close_duplicates() +b.tabs_filter("youtube") # list of matching tabs +b.tabs_query("pull request") +b.tabs_count("github") # int +html = b.tabs_html() # full HTML string of active tab +b.tabs_sort(by="domain") +b.tabs_merge_windows() +b.tabs_dedupe() + +# Tab groups +groups = b.group_list() # list of dicts: id, title, color, collapsed, tabCount +b.group_open("research") # creates group, returns { id, name } +b.group_close(42) +b.group_tabs(42) # tabs inside a group + +# Windows +windows = b.windows_list() +b.windows_rename(1, "work") +b.windows_open() +b.windows_close(1) + +# DOM (active tab must be http/https) +elements = b.dom_query("h2") # list of { tag, text, attrs } +texts = b.dom_text(".article p") # list of strings +attrs = b.dom_attr("a", "href") # list of strings +exists = b.dom_exists(".cookie-banner")# bool +b.dom_click(".accept-button") +b.dom_type("#search", "hello world") + +# Extract +links = b.extract_links() # list of { text, href } +images = b.extract_images() # list of { alt, src } +text = b.extract_text() # string +data = b.extract_json("#app-data") # parsed Python object + +# Sessions +b.session_save("before-meeting") +b.session_load("before-meeting") +sessions = b.session_list() # [{ name, tabs, savedAt }, ...] +b.session_remove("before-meeting") +diff = b.session_diff("session-a", "session-b") +# diff = { "added": [...urls], "removed": [...urls] } +b.session_auto_save(True) + +# Misc +clients = b.clients() +``` + +**Error handling** + +```python +from browser_cli import BrowserCLI, BrowserNotConnected + +b = BrowserCLI() +try: + tabs = b.tabs_list() +except BrowserNotConnected: + print("Browser is not running or extension is not loaded") +except RuntimeError as e: + print(f"Browser returned an error: {e}") +``` + +--- + +## Example scripts + +See `examples/demo.py` (Python) and `examples/demo.sh` (Bash) for full walkthroughs covering tabs, groups, DOM extraction, and session management. + +```sh +uv run python examples/demo.py +bash examples/demo.sh +``` + +--- + +## Limitations + +- **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. +- **Profile switching** via `windows open --profile` opens a plain window; launching a different profile requires the browser to be started externally with `--profile-directory`. +- **One browser at a time** — the native host socket supports one connected extension. Running multiple browser profiles simultaneously is not supported. +- **Linux and macOS only** — Windows native messaging paths are not yet handled. diff --git a/browser-cli-native-host b/browser-cli-native-host new file mode 100755 index 0000000..f4f26f2 --- /dev/null +++ b/browser-cli-native-host @@ -0,0 +1,2 @@ +#!/bin/sh +exec "/tmp/browser-cli/.venv/bin/python3" "/tmp/browser-cli/browser_cli/native_host.py" "$@" diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py new file mode 100644 index 0000000..947ae4a --- /dev/null +++ b/browser_cli/__init__.py @@ -0,0 +1,168 @@ +""" +browser_cli — Python API for controlling your running browser. + +Usage: + from browser_cli import BrowserCLI + b = BrowserCLI() + tabs = b.tabs_list() + b.open("https://example.com") +""" +from browser_cli.client import BrowserNotConnected, send_command + +__all__ = ["BrowserCLI", "BrowserNotConnected"] + + +class BrowserCLI: + # ── Navigation ──────────────────────────────────────────────────────── + + def open(self, url: str, *, background: bool = False, window: str | None = None): + return send_command("navigate.open", {"url": url, "background": background, "window": window}) + + def reload(self, tab_id: int | None = None): + return send_command("navigate.reload", {"tabId": tab_id}) + + def hard_reload(self, tab_id: int | None = None): + return send_command("navigate.hard_reload", {"tabId": tab_id}) + + def back(self, tab_id: int | None = None): + return send_command("navigate.back", {"tabId": tab_id}) + + def forward(self, tab_id: int | None = None): + return send_command("navigate.forward", {"tabId": tab_id}) + + def focus_url(self, pattern: str): + return send_command("navigate.focus", {"pattern": pattern}) + + # ── Tabs ────────────────────────────────────────────────────────────── + + def tabs_list(self) -> list[dict]: + return send_command("tabs.list", {}) + + def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False): + return send_command("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + + def tabs_move(self, tab_id: int, *, group_id: int | None = None, window_id: int | None = None): + return send_command("tabs.move", {"tabId": tab_id, "groupId": group_id, "windowId": window_id}) + + def tabs_active(self, tab_id: int): + return send_command("tabs.active", {"tabId": tab_id}) + + def tabs_filter(self, pattern: str) -> list[dict]: + return send_command("tabs.filter", {"pattern": pattern}) + + def tabs_count(self, pattern: str | None = None) -> int: + return send_command("tabs.count", {"pattern": pattern}) + + def tabs_query(self, search: str) -> list[dict]: + return send_command("tabs.query", {"search": search}) + + def tabs_html(self, tab_id: int | None = None) -> str: + return send_command("tabs.html", {"tabId": tab_id}) + + def tabs_dedupe(self): + return send_command("tabs.dedupe", {}) + + def tabs_sort(self, by: str = "domain"): + return send_command("tabs.sort", {"by": by}) + + def tabs_merge_windows(self): + return send_command("tabs.merge_windows", {}) + + def tabs_close_inactive(self): + return send_command("tabs.close", {"inactive": True}) + + def tabs_close_duplicates(self): + return send_command("tabs.close", {"duplicates": True}) + + # ── Tab Groups ──────────────────────────────────────────────────────── + + def group_list(self) -> list[dict]: + return send_command("group.list", {}) + + def group_tabs(self, group_id: int) -> list[dict]: + return send_command("group.tabs", {"groupId": group_id}) + + def group_count(self) -> int: + return send_command("group.count", {}) + + def group_query(self, search: str) -> list[dict]: + return send_command("group.query", {"search": search}) + + def group_close(self, group_id: int): + return send_command("group.close", {"groupId": group_id}) + + def group_open(self, name: str) -> dict: + return send_command("group.open", {"name": name}) + + # ── Windows ─────────────────────────────────────────────────────────── + + def windows_list(self) -> list[dict]: + return send_command("windows.list", {}) + + def windows_rename(self, window_id: int, name: str): + return send_command("windows.rename", {"windowId": window_id, "name": name}) + + def windows_close(self, window_id: int): + return send_command("windows.close", {"windowId": window_id}) + + def windows_open(self, profile: str | None = None) -> dict: + return send_command("windows.open", {"profile": profile}) + + # ── DOM ─────────────────────────────────────────────────────────────── + + def dom_query(self, selector: str) -> list[dict]: + return send_command("dom.query", {"selector": selector}) + + def dom_click(self, selector: str): + return send_command("dom.click", {"selector": selector}) + + def dom_type(self, selector: str, text: str): + return send_command("dom.type", {"selector": selector, "text": text}) + + def dom_attr(self, selector: str, attr: str) -> list[str]: + return send_command("dom.attr", {"selector": selector, "attr": attr}) + + def dom_text(self, selector: str) -> list[str]: + return send_command("dom.text", {"selector": selector}) + + def dom_exists(self, selector: str) -> bool: + return send_command("dom.exists", {"selector": selector}) + + # ── Extract ─────────────────────────────────────────────────────────── + + def extract_links(self) -> list[dict]: + return send_command("extract.links", {}) + + def extract_images(self) -> list[dict]: + return send_command("extract.images", {}) + + def extract_text(self) -> str: + return send_command("extract.text", {}) + + def extract_json(self, selector: str): + return send_command("extract.json", {"selector": selector}) + + # ── Session ─────────────────────────────────────────────────────────── + + def session_save(self, name: str): + return send_command("session.save", {"name": name}) + + def session_load(self, name: str): + return send_command("session.load", {"name": name}) + + def session_diff(self, name_a: str, name_b: str) -> dict: + return send_command("session.diff", {"nameA": name_a, "nameB": name_b}) + + def session_list(self) -> list[dict]: + return send_command("session.list", {}) + + def session_remove(self, name: str): + return send_command("session.remove", {"name": name}) + + def session_auto_save(self, enabled: bool): + return send_command("session.auto_save", {"enabled": enabled}) + + # ── Misc ────────────────────────────────────────────────────────────── + + def clients(self) -> list[dict]: + return send_command("clients.list", {}) diff --git a/browser_cli/cli.py b/browser_cli/cli.py new file mode 100644 index 0000000..66ded8f --- /dev/null +++ b/browser_cli/cli.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +browser-cli — Control your running browser from the terminal. +""" +import click +import sys +import os +import json +import stat +from pathlib import Path +from rich.console import Console + +from browser_cli.commands.navigate import cmd_open, cmd_reload, cmd_hard_reload, cmd_back, cmd_forward, cmd_focus +from browser_cli.commands.tabs import tabs_group +from browser_cli.commands.groups import group_group +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.client import send_command, BrowserNotConnected + +console = Console() + +NATIVE_HOST_NAME = "com.browsercli.host" + +NATIVE_HOST_DIRS = { + "chrome": { + "linux": [Path.home() / ".config/google-chrome/NativeMessagingHosts"], + "darwin": [Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts"], + }, + "chromium": { + "linux": [Path.home() / ".config/chromium/NativeMessagingHosts"], + "darwin": [Path.home() / "Library/Application Support/Chromium/NativeMessagingHosts"], + }, + "brave": { + "linux": [Path.home() / ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"], + "darwin": [Path.home() / "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts"], + }, +} + + +@click.group() +def main(): + """Control your running browser from the terminal via a Chrome extension.""" + + +# ── Top-level navigation commands ───────────────────────────────────────────── +main.add_command(cmd_open, name="open") +main.add_command(cmd_reload, name="reload") +main.add_command(cmd_hard_reload, name="hard-reload") +main.add_command(cmd_back, name="back") +main.add_command(cmd_forward, name="forward") +main.add_command(cmd_focus, name="focus") + +# ── Sub-command groups ───────────────────────────────────────────────────────── +main.add_command(tabs_group) +main.add_command(group_group) +main.add_command(windows_group) +main.add_command(dom_group) +main.add_command(extract_group) +main.add_command(session_group) + + +# ── clients ──────────────────────────────────────────────────────────────────── + +@main.command("clients") +def cmd_clients(): + """Show connected browser clients.""" + try: + clients = send_command("clients.list") + except BrowserNotConnected as e: + console.print(f"[red]Error:[/red] {e}") + sys.exit(1) + + from rich.table import Table + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Browser") + table.add_column("Version") + table.add_column("Platform") + for c in (clients or []): + table.add_row(c.get("name", ""), c.get("version", ""), c.get("platform", "")) + console.print(table) + + +# ── install ──────────────────────────────────────────────────────────────────── + +@main.command("install") +@click.argument("browser", type=click.Choice(["chrome", "chromium", "brave"]), default="chrome") +def cmd_install(browser): + """Register the native messaging host and print extension load instructions.""" + + # Find the native_host.py path + native_host_script = Path(__file__).parent / "native_host.py" + if not native_host_script.exists(): + console.print(f"[red]Cannot find native_host.py at {native_host_script}[/red]") + sys.exit(1) + + # Build a wrapper shell script so it's executable by Chrome + wrapper_path = Path(__file__).parent.parent / "browser-cli-native-host" + python_exe = sys.executable + wrapper_content = f"""#!/bin/sh +exec "{python_exe}" "{native_host_script}" "$@" +""" + wrapper_path.write_text(wrapper_content) + wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + # Ask for extension ID + ext_url = "brave://extensions" if browser == "brave" else "chrome://extensions" + console.print("\n[bold]Step 1:[/bold] Load the extension in your browser") + console.print(f" 1. Open [cyan]{ext_url}[/cyan]") + 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 / 'extension'}[/cyan]") + console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n") + + extension_id = click.prompt("Paste your extension ID here") + extension_id = extension_id.strip() + + # Build native messaging manifest + manifest = { + "name": NATIVE_HOST_NAME, + "description": "browser-cli native messaging host", + "path": str(wrapper_path), + "type": "stdio", + "allowed_origins": [f"chrome-extension://{extension_id}/"], + } + + # Write to OS native messaging dirs + platform = "darwin" if sys.platform == "darwin" else "linux" + dirs = NATIVE_HOST_DIRS[browser][platform] + + installed = [] + for d in dirs: + try: + d.mkdir(parents=True, exist_ok=True) + manifest_path = d / f"{NATIVE_HOST_NAME}.json" + manifest_path.write_text(json.dumps(manifest, indent=2)) + installed.append(manifest_path) + except Exception as e: + console.print(f"[yellow]Could not write to {d}: {e}[/yellow]") + + if not installed: + console.print("[red]Failed to install native host manifest[/red]") + sys.exit(1) + + for p in installed: + console.print(f"[green]✓[/green] Wrote native host manifest: {p}") + + console.print("\n[bold]Step 2:[/bold] Restart Chrome completely (Cmd/Ctrl+Q, then reopen)") + console.print("\n[green bold]✓ Installation complete![/green bold]") + console.print(" After restarting Chrome, try: [cyan]browser-cli tabs list[/cyan]") + + +if __name__ == "__main__": + main() diff --git a/browser_cli/client.py b/browser_cli/client.py new file mode 100644 index 0000000..ee4135d --- /dev/null +++ b/browser_cli/client.py @@ -0,0 +1,61 @@ +""" +Unix socket client — sends commands to the native host relay socket. +Used by both the CLI and the public Python API. +""" +import json +import socket +import struct +import uuid +from typing import Any + +SOCKET_PATH = "/tmp/browser-cli.sock" + + +class BrowserNotConnected(Exception): + """Raised when the native host socket is not available.""" + + +def send_command(command: str, args: dict | None = None) -> Any: + """Send a command to the browser and return the response data.""" + msg = { + "id": str(uuid.uuid4()), + "command": command, + "args": args or {}, + } + payload = json.dumps(msg).encode("utf-8") + framed = struct.pack(" bytes: + raw_len = _recv_exact(sock, 4) + msg_len = struct.unpack(" bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError("Socket closed before full message received") + buf += chunk + return buf diff --git a/browser_cli/commands/__init__.py b/browser_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browser_cli/commands/dom.py b/browser_cli/commands/dom.py new file mode 100644 index 0000000..ed1f000 --- /dev/null +++ b/browser_cli/commands/dom.py @@ -0,0 +1,89 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table +import json + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +@click.group("dom") +def dom_group(): + """Query and interact with page DOM elements.""" + + +@dom_group.command("query") +@click.argument("selector") +def dom_query(selector): + """Return elements matching CSS SELECTOR (like mini DevTools).""" + elements = _handle("dom.query", {"selector": selector}) + if not elements: + console.print("[yellow]No elements found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Tag", width=12) + table.add_column("Text", width=40) + table.add_column("Attributes") + for el in elements: + attrs = ", ".join(f"{k}={v!r}" for k, v in (el.get("attrs") or {}).items()) + table.add_row(el.get("tag", ""), (el.get("text") or "")[:60], attrs[:80]) + console.print(table) + + +@dom_group.command("click") +@click.argument("selector") +def dom_click(selector): + """Click the first element matching CSS SELECTOR.""" + _handle("dom.click", {"selector": selector}) + console.print(f"[green]Clicked:[/green] {selector}") + + +@dom_group.command("type") +@click.argument("selector") +@click.argument("text") +def dom_type(selector, text): + """Type TEXT into the element matching CSS SELECTOR.""" + _handle("dom.type", {"selector": selector, "text": text}) + console.print(f"[green]Typed into:[/green] {selector}") + + +@dom_group.command("attr") +@click.argument("selector") +@click.argument("attr_name") +def dom_attr(selector, attr_name): + """Get attribute ATTR_NAME from elements matching CSS SELECTOR.""" + values = _handle("dom.attr", {"selector": selector, "attr": attr_name}) + for v in (values or []): + console.print(v) + + +@dom_group.command("text") +@click.argument("selector") +def dom_text(selector): + """Get text content of elements matching CSS SELECTOR.""" + values = _handle("dom.text", {"selector": selector}) + for v in (values or []): + console.print(v) + + +@dom_group.command("exists") +@click.argument("selector") +def dom_exists(selector): + """Check if an element matching CSS SELECTOR exists on the page.""" + exists = _handle("dom.exists", {"selector": selector}) + if exists: + console.print(f"[green]exists[/green]: {selector}") + else: + console.print(f"[red]not found[/red]: {selector}") + raise SystemExit(1) diff --git a/browser_cli/commands/extract.py b/browser_cli/commands/extract.py new file mode 100644 index 0000000..66e7d65 --- /dev/null +++ b/browser_cli/commands/extract.py @@ -0,0 +1,68 @@ +import click +import json +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +@click.group("extract") +def extract_group(): + """Extract content from the active tab.""" + + +@extract_group.command("links") +def extract_links(): + """Extract all links from the active tab.""" + links = _handle("extract.links") + if not links: + console.print("[yellow]No links found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Text", width=40) + table.add_column("URL") + for lnk in links: + table.add_row((lnk.get("text") or "")[:60], lnk.get("href") or "") + console.print(table) + + +@extract_group.command("images") +def extract_images(): + """Extract all images from the active tab.""" + images = _handle("extract.images") + if not images: + console.print("[yellow]No images found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Alt", width=30) + table.add_column("Src") + for img in images: + table.add_row((img.get("alt") or "")[:40], img.get("src") or "") + console.print(table) + + +@extract_group.command("text") +def extract_text(): + """Extract all visible text from the active tab.""" + text = _handle("extract.text") + console.print(text or "") + + +@extract_group.command("json") +@click.argument("selector") +def extract_json(selector): + """Parse and pretty-print JSON content inside SELECTOR.""" + data = _handle("extract.json", {"selector": selector}) + console.print_json(json.dumps(data)) diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py new file mode 100644 index 0000000..e00b15b --- /dev/null +++ b/browser_cli/commands/groups.py @@ -0,0 +1,102 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +def _print_groups(groups: list[dict]) -> None: + if not groups: + console.print("[yellow]No groups found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Name") + table.add_column("Color", width=10) + table.add_column("Collapsed", width=10) + table.add_column("Tabs", width=6) + for g in groups: + table.add_row( + str(g.get("id", "")), + g.get("title") or "(unnamed)", + g.get("color") or "", + "yes" if g.get("collapsed") else "no", + str(g.get("tabCount", "")), + ) + console.print(table) + + +@click.group("group") +def group_group(): + """Manage tab groups.""" + + +@group_group.command("list") +def group_list(): + """List all tab groups.""" + groups = _handle("group.list") + _print_groups(groups or []) + + +@group_group.command("tabs") +@click.argument("group_id", type=int) +def group_tabs(group_id): + """List tabs inside a group.""" + from browser_cli.commands.tabs import _print_tabs + tabs = _handle("group.tabs", {"groupId": group_id}) + _print_tabs(tabs or []) + + +@group_group.command("count") +def group_count(): + """Count all tab groups.""" + count = _handle("group.count") + console.print(f"[bold]{count}[/bold] group(s)") + + +@group_group.command("query") +@click.argument("search") +def group_query(search): + """Search groups by name.""" + groups = _handle("group.query", {"search": search}) + _print_groups(groups or []) + + +@group_group.command("close") +@click.argument("group_id", type=int) +def group_close(group_id): + """Close (ungroup and optionally close) a tab group.""" + _handle("group.close", {"groupId": group_id}) + console.print(f"[green]Group {group_id} closed[/green]") + + +@group_group.command("create") +@click.argument("name") +def group_create(name): + """Create a new tab group with NAME.""" + result = _handle("group.open", {"name": name}) + gid = result.get("id") if isinstance(result, dict) else result + console.print(f"[green]Created group '{name}'[/green] (id: {gid})") + + +@group_group.command("add-tab") +@click.argument("group") +@click.argument("url", required=False) +def group_add_tab(group, url): + """Open a new tab (optionally at URL) inside GROUP (name or ID).""" + result = _handle("group.add_tab", {"group": group, "url": url}) + tab_id = result.get("tabId") if isinstance(result, dict) else result + label = url or "new tab" + console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})") diff --git a/browser_cli/commands/navigate.py b/browser_cli/commands/navigate.py new file mode 100644 index 0000000..9f4a348 --- /dev/null +++ b/browser_cli/commands/navigate.py @@ -0,0 +1,75 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console + +console = Console() + + +def _handle(command, args): + try: + return send_command(command, args) + 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) + + +@click.command("open") +@click.argument("url") +@click.option("--bg", is_flag=True, help="Open in background (no focus)") +@click.option("--window", "window_name", default=None, help="Open in named window") +@click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)") +def cmd_open(url, bg, window_name, group_name): + """Open URL in a new tab.""" + result = _handle("navigate.open", {"url": url, "background": bg, "window": window_name, "group": group_name}) + suffix = "" + if group_name: + suffix = f" in group '{group_name}'" + elif window_name: + suffix = f" in window '{window_name}'" + console.print(f"[green]Opened:[/green] {url}{suffix}") + + +@click.command("reload") +@click.argument("tab_id", type=int, required=False) +def cmd_reload(tab_id): + """Reload the active (or specified) tab.""" + _handle("navigate.reload", {"tabId": tab_id}) + console.print("[green]Reloaded[/green]") + + +@click.command("hard-reload") +@click.argument("tab_id", type=int, required=False) +def cmd_hard_reload(tab_id): + """Hard reload (bypass cache) the active (or specified) tab.""" + _handle("navigate.hard_reload", {"tabId": tab_id}) + console.print("[green]Hard reloaded[/green]") + + +@click.command("back") +@click.argument("tab_id", type=int, required=False) +def cmd_back(tab_id): + """Navigate back in the active (or specified) tab.""" + _handle("navigate.back", {"tabId": tab_id}) + console.print("[green]Navigated back[/green]") + + +@click.command("forward") +@click.argument("tab_id", type=int, required=False) +def cmd_forward(tab_id): + """Navigate forward in the active (or specified) tab.""" + _handle("navigate.forward", {"tabId": tab_id}) + console.print("[green]Navigated forward[/green]") + + +@click.command("focus") +@click.argument("pattern") +def cmd_focus(pattern): + """Jump to the first tab whose URL matches PATTERN.""" + result = _handle("navigate.focus", {"pattern": pattern}) + if result: + console.print(f"[green]Focused:[/green] {result.get('url', result)}") + else: + console.print(f"[yellow]No tab found matching:[/yellow] {pattern}") diff --git a/browser_cli/commands/session.py b/browser_cli/commands/session.py new file mode 100644 index 0000000..c0239f6 --- /dev/null +++ b/browser_cli/commands/session.py @@ -0,0 +1,103 @@ +import click +import json +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +@click.group("session") +def session_group(): + """Save and restore browser sessions.""" + + +@session_group.command("save") +@click.argument("name") +def session_save(name): + """Save all current tabs as session NAME.""" + result = _handle("session.save", {"name": name}) + count = result.get("tabs", 0) if isinstance(result, dict) else 0 + console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)") + + +@session_group.command("load") +@click.argument("name") +def session_load(name): + """Restore session NAME (opens all saved tabs).""" + result = _handle("session.load", {"name": name}) + count = result.get("tabs", 0) if isinstance(result, dict) else 0 + console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)") + + +@session_group.command("diff") +@click.argument("name_a") +@click.argument("name_b") +def session_diff(name_a, name_b): + """Show tabs added/removed between two saved sessions.""" + diff = _handle("session.diff", {"nameA": name_a, "nameB": name_b}) + if not diff: + console.print("[yellow]No diff data returned[/yellow]") + return + + added = diff.get("added") or [] + removed = diff.get("removed") or [] + + if added: + console.print(f"[green]Added in '{name_b}':[/green]") + for url in added: + console.print(f" + {url}") + + if removed: + console.print(f"[red]Removed in '{name_b}':[/red]") + for url in removed: + console.print(f" - {url}") + + if not added and not removed: + console.print("[green]Sessions are identical[/green]") + + +@session_group.command("list") +def session_list(): + """List all saved sessions.""" + from rich.table import Table + sessions = _handle("session.list") + if not sessions: + console.print("[yellow]No saved sessions[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Name") + table.add_column("Tabs", width=6) + table.add_column("Saved at") + for s in sessions: + from datetime import datetime + saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else "" + table.add_row(s["name"], str(s["tabs"]), saved) + console.print(table) + + +@session_group.command("remove") +@click.argument("name") +def session_remove(name): + """Delete a saved session.""" + _handle("session.remove", {"name": name}) + console.print(f"[green]Session '{name}' removed[/green]") + + +@session_group.command("auto-save") +@click.argument("state", type=click.Choice(["on", "off"])) +def session_auto_save(state): + """Enable or disable automatic session saving.""" + enabled = state == "on" + _handle("session.auto_save", {"enabled": enabled}) + console.print(f"[green]Auto-save {state}[/green]") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py new file mode 100644 index 0000000..3f29c8e --- /dev/null +++ b/browser_cli/commands/tabs.py @@ -0,0 +1,138 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +def _print_tabs(tabs: list[dict]) -> None: + if not tabs: + console.print("[yellow]No tabs found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Window", no_wrap=True) + table.add_column("Active", width=7) + table.add_column("Title") + table.add_column("URL") + for t in tabs: + active = "[green]✓[/green]" if t.get("active") else "" + table.add_row( + str(t.get("id", "")), + str(t.get("windowId", "")), + active, + (t.get("title") or "")[:60], + (t.get("url") or "")[:80], + ) + console.print(table) + + +@click.group("tabs") +def tabs_group(): + """Manage browser tabs.""" + + +@tabs_group.command("list") +def tabs_list(): + """List all open tabs across all windows.""" + tabs = _handle("tabs.list") + _print_tabs(tabs or []) + + +@tabs_group.command("close") +@click.argument("tab_id", type=int, required=False) +@click.option("--inactive", is_flag=True, help="Close all inactive tabs") +@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)") +def tabs_close(tab_id, inactive, duplicates): + """Close a tab, all inactive tabs, or all duplicate tabs.""" + result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + count = result.get("closed", 0) if isinstance(result, dict) else 1 + console.print(f"[green]Closed {count} tab(s)[/green]") + + +@tabs_group.command("move") +@click.argument("tab_id", type=int) +@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID") +@click.option("--window", "window_id", type=int, default=None, help="Move to window ID") +@click.option("--index", type=int, default=None, help="Position index in target") +def tabs_move(tab_id, group_id, window_id, index): + """Move a tab to a different window or group.""" + _handle("tabs.move", {"tabId": tab_id, "groupId": group_id, "windowId": window_id, "index": index}) + console.print("[green]Tab moved[/green]") + + +@tabs_group.command("active") +@click.argument("tab_id", type=int) +def tabs_active(tab_id): + """Switch browser focus to a tab.""" + _handle("tabs.active", {"tabId": tab_id}) + console.print(f"[green]Switched to tab {tab_id}[/green]") + + +@tabs_group.command("filter") +@click.argument("pattern") +def tabs_filter(pattern): + """List tabs whose URL contains PATTERN.""" + tabs = _handle("tabs.filter", {"pattern": pattern}) + _print_tabs(tabs or []) + + +@tabs_group.command("count") +@click.argument("pattern", required=False) +def tabs_count(pattern): + """Count open tabs, optionally filtered by URL PATTERN.""" + count = _handle("tabs.count", {"pattern": pattern}) + label = f" matching '{pattern}'" if pattern else "" + console.print(f"[bold]{count}[/bold] tab(s){label}") + + +@tabs_group.command("query") +@click.argument("search") +def tabs_query(search): + """Search tabs by URL or title.""" + tabs = _handle("tabs.query", {"search": search}) + _print_tabs(tabs or []) + + +@tabs_group.command("html") +@click.argument("tab_id", type=int, required=False) +def tabs_html(tab_id): + """Print the full HTML of a tab.""" + html = _handle("tabs.html", {"tabId": tab_id}) + console.print(html or "") + + +@tabs_group.command("dedupe") +def tabs_dedupe(): + """Close duplicate tabs (keep the first occurrence of each URL).""" + result = _handle("tabs.dedupe") + count = result.get("closed", 0) if isinstance(result, dict) else 0 + console.print(f"[green]Closed {count} duplicate tab(s)[/green]") + + +@tabs_group.command("sort") +@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) +def tabs_sort(by): + """Sort tabs within each window.""" + _handle("tabs.sort", {"by": by}) + console.print(f"[green]Tabs sorted by {by}[/green]") + + +@tabs_group.command("merge-windows") +def tabs_merge_windows(): + """Move all tabs into the focused window.""" + result = _handle("tabs.merge_windows") + count = result.get("moved", 0) if isinstance(result, dict) else 0 + console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py new file mode 100644 index 0000000..547f9a1 --- /dev/null +++ b/browser_cli/commands/windows.py @@ -0,0 +1,77 @@ +import click +from browser_cli.client import send_command, BrowserNotConnected +from rich.console import Console +from rich.table import Table + +console = Console() + + +def _handle(command, args=None): + try: + return send_command(command, args or {}) + 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) + + +def _print_windows(windows: list[dict]) -> None: + if not windows: + console.print("[yellow]No windows found[/yellow]") + return + table = Table(show_header=True, header_style="bold cyan") + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Alias", width=20) + table.add_column("Focused", width=8) + table.add_column("Tabs", width=6) + table.add_column("State", width=12) + for w in windows: + focused = "[green]✓[/green]" if w.get("focused") else "" + table.add_row( + str(w.get("id", "")), + w.get("alias") or "", + focused, + str(w.get("tabCount", "")), + w.get("state") or "", + ) + console.print(table) + + +@click.group("windows") +def windows_group(): + """Manage browser windows.""" + + +@windows_group.command("list") +def windows_list(): + """List all browser windows.""" + windows = _handle("windows.list") + _print_windows(windows or []) + + +@windows_group.command("rename") +@click.argument("window_id", type=int) +@click.argument("name") +def windows_rename(window_id, name): + """Give a window a local alias NAME (stored in native host).""" + _handle("windows.rename", {"windowId": window_id, "name": name}) + console.print(f"[green]Window {window_id} aliased as '{name}'[/green]") + + +@windows_group.command("close") +@click.argument("window_id", type=int) +def windows_close(window_id): + """Close a browser window.""" + _handle("windows.close", {"windowId": window_id}) + console.print(f"[green]Window {window_id} closed[/green]") + + +@windows_group.command("open") +@click.option("--profile", default=None, help="Open with a specific Chrome profile name") +def windows_open(profile): + """Open a new browser window.""" + result = _handle("windows.open", {"profile": profile}) + wid = result.get("id") if isinstance(result, dict) else result + console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with profile '{profile}'" if profile else "")) diff --git a/browser_cli/native_host.py b/browser_cli/native_host.py new file mode 100644 index 0000000..6f73af5 --- /dev/null +++ b/browser_cli/native_host.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Native Messaging Host for browser-cli. + +Chrome launches this process when the extension calls connectNative(). +It relays messages between the Chrome extension (via stdin/stdout using the +Native Messaging protocol) and the CLI (via a Unix domain socket). +""" +import json +import os +import queue +import socket +import struct +import sys +import threading +import uuid +from pathlib import Path + +SOCKET_PATH = "/tmp/browser-cli.sock" +PENDING: dict[str, queue.Queue] = {} +PENDING_LOCK = threading.Lock() + +# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) --- + +def read_native_message(stream) -> dict | None: + raw_len = stream.read(4) + if len(raw_len) < 4: + return None + msg_len = struct.unpack(" None: + data = json.dumps(msg).encode("utf-8") + stream.write(struct.pack(" None: + try: + data = _recv_all(conn) + if not data: + return + cmd = json.loads(data) + if "id" not in cmd: + cmd["id"] = str(uuid.uuid4()) + + msg_id = cmd["id"] + response_queue: queue.Queue = queue.Queue() + + with PENDING_LOCK: + PENDING[msg_id] = response_queue + + # Forward command to extension via stdout + write_native_message(sys.stdout.buffer, cmd) + + # Wait for extension's response (30 s timeout) + try: + result = response_queue.get(timeout=30) + except queue.Empty: + result = {"id": msg_id, "success": False, "error": "timeout waiting for browser response"} + + with PENDING_LOCK: + PENDING.pop(msg_id, None) + + _send_all(conn, json.dumps(result).encode("utf-8")) + except Exception as exc: + try: + _send_all(conn, json.dumps({"success": False, "error": str(exc)}).encode("utf-8")) + except Exception: + pass + finally: + conn.close() + + +# --- Socket helpers (length-prefixed framing) --- + +def _send_all(conn: socket.socket, data: bytes) -> None: + framed = struct.pack(" bytes | None: + raw_len = _recv_exact(conn, 4) + if raw_len is None: + return None + msg_len = struct.unpack(" bytes | None: + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +def _cleanup(): + try: + Path(SOCKET_PATH).unlink(missing_ok=True) + except Exception: + pass + + +def main(): + # Start socket server thread + t = threading.Thread(target=socket_server, daemon=True) + t.start() + + # Read extension messages on main thread (blocks until extension disconnects) + stdin_reader() + + +if __name__ == "__main__": + main() diff --git a/com.browsercli.host.json b/com.browsercli.host.json new file mode 100644 index 0000000..598b7c4 --- /dev/null +++ b/com.browsercli.host.json @@ -0,0 +1,9 @@ +{ + "name": "com.browsercli.host", + "description": "browser-cli native messaging host", + "path": "/REPLACE_WITH_ABSOLUTE_PATH/browser-cli-native-host", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://REPLACE_WITH_EXTENSION_ID/" + ] +} diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..55cf71e --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,107 @@ +""" +browser-cli Python API demo +---------------------------- +Shows how to manage your running browser from a Python script. + +Run with: + uv run python examples/demo.py +""" +from browser_cli import BrowserCLI + +b = BrowserCLI() + + +# ── 1. See what's open ──────────────────────────────────────────────────────── +print("=== Open tabs ===") +tabs = b.tabs_list() +for tab in tabs: + active = " <-- active" if tab["active"] else "" + print(f" [{tab['id']}] (window {tab['windowId']}) {tab['title'][:50]}{active}") + + +# ── 2. Count tabs per domain ────────────────────────────────────────────────── +print("\n=== Tabs per domain ===") +from urllib.parse import urlparse +from collections import Counter + +domains = Counter( + urlparse(t["url"]).netloc + for t in tabs + if t.get("url") and not t["url"].startswith("chrome") +) +for domain, count in domains.most_common(5): + print(f" {count:>3}x {domain}") + + +# ── 3. Open a group and add tabs to it ─────────────────────────────────────── +print("\n=== Creating 'demo' tab group ===") +group = b.group_open("demo") +group_id = group["id"] +print(f" Created group id: {group_id}") + +urls = [ + "https://example.com", + "https://wikipedia.org", +] +first_tab_id = None +for url in urls: + b.open(url, background=True) + # find the tab we just opened and move it into the group + fresh = b.tabs_list() + new_tab = next((t for t in reversed(fresh) if t.get("url", "").startswith(url[:20])), None) + if new_tab: + b.tabs_move(new_tab["id"], group_id=group_id) + print(f" Added {url} → tab {new_tab['id']}") + if first_tab_id is None: + first_tab_id = new_tab["id"] + +# Activate the first opened tab so DOM/extract have a real page to work with +if first_tab_id: + b.tabs_active(first_tab_id) + print(f" Switched to tab {first_tab_id} for DOM demo") + + +# ── 4. Extract links from the active tab ───────────────────────────────────── +# Re-fetch tabs so we have the current active tab (it may have changed) +current_tabs = b.tabs_list() +active_tab = next((t for t in current_tabs if t.get("active")), None) +active_url = active_tab.get("url", "") if active_tab else "" +scriptable = active_url.startswith("http://") or active_url.startswith("https://") + +print("\n=== Links on active tab ===") +if not scriptable: + print(f" Skipped — active tab is {active_url!r} (not a web page)") +else: + links = b.extract_links() + for link in links[:5]: + print(f" {link['text'][:30]:<32} {link['href'][:60]}") + if len(links) > 5: + print(f" ... and {len(links) - 5} more") + + +# ── 5. Check if an element exists, then read its text ──────────────────────── +print("\n=== DOM: page heading ===") +if not scriptable: + print(f" Skipped — active tab is {active_url!r} (not a web page)") +elif b.dom_exists("h1"): + headings = b.dom_text("h1") + for h in headings: + print(f"

{h}") +else: + print(" No

found on active tab") + + +# ── 6. Save current session and show how to restore ────────────────────────── +print("\n=== Saving session as 'demo-session' ===") +result = b.session_save("demo-session") +print(f" Saved {result['tabs']} tabs") +print(" Restore later with: b.session_load('demo-session')") + + +# ── 7. Clean up: close inactive tabs ───────────────────────────────────────── +print("\n=== Closing duplicate tabs ===") +result = b.tabs_close_duplicates() +closed = result.get("closed", 0) if isinstance(result, dict) else 0 +print(f" Closed {closed} duplicate tab(s)") + +print("\nDone!") diff --git a/examples/demo.sh b/examples/demo.sh new file mode 100755 index 0000000..00c9d60 --- /dev/null +++ b/examples/demo.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# browser-cli Bash demo +# ---------------------- +# Shows how to drive your running browser from a shell script. +# +# Run with: +# bash examples/demo.sh +# +# Press ENTER to advance each step, or set AUTO=1 to run without pausing: +# AUTO=1 bash examples/demo.sh + +CLI="uv run browser-cli" +DELAY=2 # seconds between steps in auto mode + +pause() { + echo "" + if [ "${AUTO:-0}" = "1" ]; then + sleep "$DELAY" + else + read -rp " [press ENTER to continue] " + fi + echo "" +} + +header() { + echo "" + echo "────────────────────────────────────" + echo " $1" + echo "────────────────────────────────────" +} + + +header "1/8 · Open tabs" +$CLI tabs list +pause + +header "2/8 · Tab count & windows" +$CLI tabs count +echo "" +$CLI windows list +pause + +header "3/8 · Create 'research' group and open URLs into it" +$CLI group create research +echo "" +$CLI open https://example.com --group research --bg +$CLI open https://wikipedia.org --group research --bg +echo "" +echo " Tabs are now open inside the 'research' group in your browser." +pause + +header "4/8 · Tab hygiene — close duplicates, sort by domain" +$CLI tabs close --duplicates +echo "" +$CLI tabs sort --by domain +pause + +header "5/8 · Find tabs by URL pattern or title" +$CLI tabs filter wikipedia +echo "" +$CLI tabs query "example" +pause + +header "6/8 · DOM and content extraction (active tab)" +echo " Switching to the example.com tab first..." +$CLI focus example.com +echo "" +echo " Page headings:" +$CLI dom text h1 +echo "" +echo " Links on the page:" +$CLI extract links +pause + +header "7/8 · Session management" +$CLI session save before-meeting +echo "" +echo " Restore later with:" +echo " $CLI session load before-meeting" +echo "" +echo " Compare two sessions with:" +echo " $CLI session diff before-meeting after-meeting" +pause + +header "8/8 · Merge all windows into one" +$CLI tabs merge-windows +echo "" +echo "Done!" diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..9af7b0d --- /dev/null +++ b/extension/background.js @@ -0,0 +1,596 @@ +/** + * browser-cli Extension — Background Service Worker + * + * Connects to the native host (com.browsercli.host) via Native Messaging. + * The native host relays commands from the CLI Unix socket to this extension, + * and relays responses back. + */ + +const NATIVE_HOST = "com.browsercli.host"; +let port = null; + +// ── Connection management ───────────────────────────────────────────────────── + +function connect() { + if (port) return; + try { + port = chrome.runtime.connectNative(NATIVE_HOST); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(() => { + port = null; + const err = chrome.runtime.lastError; + if (err) console.warn("[browser-cli] Native host disconnected:", err.message); + }); + console.log("[browser-cli] Connected to native host"); + } catch (e) { + port = null; + console.error("[browser-cli] Failed to connect:", e); + } +} + +chrome.runtime.onInstalled.addListener(connect); +chrome.runtime.onStartup.addListener(connect); + +// Keepalive alarm — prevents service worker suspension and reconnects if needed +chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keepalive") { + if (!port) connect(); + } +}); + +// ── Message dispatcher ──────────────────────────────────────────────────────── + +async function onMessage(msg) { + const { id, command, args } = msg; + if (!id || !command) return; + + let data, error; + try { + data = await dispatch(command, args || {}); + } catch (e) { + error = e.message || String(e); + } + + if (error !== undefined) { + port.postMessage({ id, success: false, error }); + } else { + port.postMessage({ id, success: true, data }); + } +} + +async function dispatch(command, args) { + switch (command) { + // ── Navigation ──────────────────────────────────────────────────────── + case "navigate.open": return navOpen(args); + case "navigate.reload": return navReload(args, false); + case "navigate.hard_reload": return navReload(args, true); + case "navigate.back": return navBack(args); + case "navigate.forward": return navForward(args); + case "navigate.focus": return navFocus(args); + + // ── Tabs ────────────────────────────────────────────────────────────── + case "tabs.list": return tabsList(); + case "tabs.close": return tabsClose(args); + case "tabs.move": return tabsMove(args); + case "tabs.active": return tabsActive(args); + case "tabs.filter": return tabsFilter(args); + case "tabs.count": return tabsCount(args); + case "tabs.query": return tabsQuery(args); + case "tabs.html": return tabsHtml(args); + case "tabs.dedupe": return tabsDedupe(); + case "tabs.sort": return tabsSort(args); + case "tabs.merge_windows": return tabsMergeWindows(); + + // ── Groups ──────────────────────────────────────────────────────────── + case "group.list": return groupList(); + case "group.tabs": return groupTabs(args); + case "group.count": return groupCount(); + case "group.query": return groupQuery(args); + case "group.close": return groupClose(args); + case "group.open": return groupOpen(args); + case "group.add_tab": return groupAddTab(args); + + // ── Windows ─────────────────────────────────────────────────────────── + case "windows.list": return windowsList(); + case "windows.rename": return windowsRename(args); + case "windows.close": return windowsClose(args); + case "windows.open": return windowsOpen(args); + + // ── DOM ─────────────────────────────────────────────────────────────── + case "dom.query": return domOp("domQuery", args); + case "dom.click": return domOp("domClick", args); + case "dom.type": return domOp("domType", args); + case "dom.attr": return domOp("domAttr", args); + case "dom.text": return domOp("domText", args); + case "dom.exists": return domOp("domExists", args); + + // ── Extract ─────────────────────────────────────────────────────────── + case "extract.links": return domOp("extractLinks", args); + case "extract.images": return domOp("extractImages", args); + case "extract.text": return domOp("extractText", args); + case "extract.json": return domOp("extractJson", args); + + // ── Session ─────────────────────────────────────────────────────────── + case "session.save": return sessionSave(args); + case "session.load": return sessionLoad(args); + case "session.list": return sessionList(); + case "session.remove": return sessionRemove(args); + case "session.diff": return sessionDiff(args); + case "session.auto_save": return sessionAutoSave(args); + + // ── Misc ────────────────────────────────────────────────────────────── + case "clients.list": return clientsList(); + + default: + throw new Error(`Unknown command: ${command}`); + } +} + +// ── Navigation ──────────────────────────────────────────────────────────────── + +async function navOpen({ url, background, window: windowName, group: groupNameOrId }) { + let windowId; + if (windowName) { + const aliases = await getAliases(); + const entry = Object.entries(aliases).find(([, v]) => v === windowName); + if (entry) windowId = parseInt(entry[0]); + } + const tab = await chrome.tabs.create({ url, active: !background, windowId }); + if (groupNameOrId != null) { + const groupId = await resolveGroupId(groupNameOrId); + // Close any blank placeholder tabs that were created when the group was made + const groupTabs = await chrome.tabs.query({ groupId }); + const placeholders = groupTabs.filter(t => + t.id !== tab.id && + (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") + ); + await chrome.tabs.group({ tabIds: [tab.id], groupId }); + if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); + } + return { id: tab.id, url: tab.url }; +} + +async function navReload({ tabId }, bypassCache) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.reload(tab.id, { bypassCache }); + return { tabId: tab.id }; +} + +async function navBack({ tabId }) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goBack(tab.id); + return { tabId: tab.id }; +} + +async function navForward({ tabId }) { + const tab = tabId ? { id: tabId } : await getActiveTab(); + await chrome.tabs.goForward(tab.id); + return { tabId: tab.id }; +} + +async function navFocus({ pattern }) { + const all = await chrome.tabs.query({}); + const match = all.find(t => t.url && t.url.includes(pattern)); + if (!match) return null; + await chrome.windows.update(match.windowId, { focused: true }); + await chrome.tabs.update(match.id, { active: true }); + return { id: match.id, url: match.url, title: match.title }; +} + +// ── Tabs ────────────────────────────────────────────────────────────────────── + +async function tabsList() { + const windows = await chrome.windows.getAll({ populate: true }); + const aliases = await getAliases(); + const tabs = []; + for (const w of windows) { + for (const t of w.tabs) { + tabs.push({ + id: t.id, + windowId: t.windowId, + windowAlias: aliases[t.windowId] || null, + active: t.active, + pinned: t.pinned, + title: t.title, + url: t.url, + favIconUrl: t.favIconUrl, + groupId: t.groupId >= 0 ? t.groupId : null, + }); + } + } + return tabs; +} + +async function tabsClose({ tabId, inactive, duplicates }) { + let toClose = []; + if (duplicates) { + const all = await chrome.tabs.query({}); + const seen = new Set(); + for (const t of all) { + if (seen.has(t.url)) toClose.push(t.id); + else seen.add(t.url); + } + } else if (inactive) { + const all = await chrome.tabs.query({}); + toClose = all.filter(t => !t.active).map(t => t.id); + } else if (tabId) { + toClose = [tabId]; + } + if (toClose.length) await chrome.tabs.remove(toClose); + return { closed: toClose.length }; +} + +async function tabsMove({ tabId, groupId, windowId, index }) { + const moveProps = {}; + if (windowId != null) moveProps.windowId = windowId; + if (index != null) moveProps.index = index; + else moveProps.index = -1; + await chrome.tabs.move(tabId, moveProps); + if (groupId != null) { + await chrome.tabs.group({ tabIds: [tabId], groupId }); + } + return { tabId }; +} + +async function tabsActive({ tabId }) { + const tab = await chrome.tabs.get(tabId); + await chrome.windows.update(tab.windowId, { focused: true }); + await chrome.tabs.update(tabId, { active: true }); + return { tabId }; +} + +async function tabsFilter({ pattern }) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); +} + +async function tabsCount({ pattern }) { + const all = await chrome.tabs.query({}); + if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; + return all.length; +} + +async function tabsQuery({ search }) { + const q = search.toLowerCase(); + const all = await chrome.tabs.query({}); + return all.filter(t => + (t.url && t.url.toLowerCase().includes(q)) || + (t.title && t.title.toLowerCase().includes(q)) + ).map(tabInfo); +} + +async function tabsHtml({ tabId }) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.documentElement.outerHTML, + }); + return results[0]?.result || ""; +} + +async function tabsDedupe() { + return tabsClose({ duplicates: true }); +} + +async function tabsSort({ by }) { + const windows = await chrome.windows.getAll({ populate: true }); + let moved = 0; + for (const w of windows) { + const sorted = [...w.tabs].sort((a, b) => { + if (by === "title") return (a.title || "").localeCompare(b.title || ""); + if (by === "time") return a.id - b.id; // lower id = opened earlier + // domain (default) + const da = new URL(a.url || "about:blank").hostname; + const db = new URL(b.url || "about:blank").hostname; + return da.localeCompare(db); + }); + for (let i = 0; i < sorted.length; i++) { + await chrome.tabs.move(sorted[i].id, { index: i }); + moved++; + } + } + return { moved }; +} + +async function tabsMergeWindows() { + const [focused] = await chrome.windows.getAll({ populate: false }); + const current = await chrome.windows.getCurrent(); + const all = await chrome.windows.getAll({ populate: true }); + let moved = 0; + for (const w of all) { + if (w.id === current.id) continue; + const ids = w.tabs.map(t => t.id); + await chrome.tabs.move(ids, { windowId: current.id, index: -1 }); + moved += ids.length; + } + return { moved }; +} + +function tabInfo(t) { + return { id: t.id, windowId: t.windowId, active: t.active, title: t.title, url: t.url }; +} + +// ── Groups ──────────────────────────────────────────────────────────────────── + +async function groupList() { + const groups = await chrome.tabGroups.query({}); + const all = await chrome.tabs.query({}); + return groups.map(g => ({ + id: g.id, + title: g.title, + color: g.color, + collapsed: g.collapsed, + windowId: g.windowId, + tabCount: all.filter(t => t.groupId === g.id).length, + })); +} + +async function groupTabs({ groupId }) { + const all = await chrome.tabs.query({}); + return all.filter(t => t.groupId === groupId).map(tabInfo); +} + +async function groupCount() { + const groups = await chrome.tabGroups.query({}); + return groups.length; +} + +async function groupQuery({ search }) { + const q = search.toLowerCase(); + const groups = await chrome.tabGroups.query({}); + return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); +} + +async function groupClose({ groupId }) { + const tabs = await chrome.tabs.query({}); + const groupTabs = tabs.filter(t => t.groupId === groupId); + await chrome.tabs.ungroup(groupTabs.map(t => t.id)); + return { groupId }; +} + +async function groupOpen({ name }) { + const tab = await chrome.tabs.create({ active: true }); + const groupId = await chrome.tabs.group({ tabIds: [tab.id] }); + await chrome.tabGroups.update(groupId, { title: name }); + return { id: groupId, name }; +} + +async function groupAddTab({ group, url }) { + const groupId = await resolveGroupId(group); + const existingTabs = await chrome.tabs.query({ groupId }); + const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); + await chrome.tabs.group({ tabIds: [tab.id], groupId }); + // If a URL was provided, close any blank placeholder tabs left from group creation + if (url) { + const placeholders = existingTabs.filter(t => + t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" + ); + if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); + } + return { tabId: tab.id, groupId }; +} + +// ── Windows ─────────────────────────────────────────────────────────────────── + +async function windowsList() { + const windows = await chrome.windows.getAll({ populate: true }); + const aliases = await getAliases(); + return windows.map(w => ({ + id: w.id, + alias: aliases[w.id] || null, + focused: w.focused, + state: w.state, + tabCount: (w.tabs || []).length, + })); +} + +async function windowsRename({ windowId, name }) { + const aliases = await getAliases(); + aliases[windowId] = name; + await chrome.storage.local.set({ windowAliases: aliases }); + return { windowId, name }; +} + +async function windowsClose({ windowId }) { + await chrome.windows.remove(windowId); + return { windowId }; +} + +async function windowsOpen({ profile }) { + // profile support requires launching Chrome with --profile-directory which + // isn't possible from within an extension — we open a plain new window. + const w = await chrome.windows.create({ focused: true }); + return { id: w.id }; +} + +// ── DOM / Extract ───────────────────────────────────────────────────────────── + +function isScriptableUrl(url) { + if (!url) return false; + return !url.startsWith("chrome://") && + !url.startsWith("brave://") && + !url.startsWith("about:") && + !url.startsWith("edge://") && + !url.startsWith("chrome-extension://"); +} + +async function domOp(funcName, args) { + const tab = await getActiveTab(); + if (!isScriptableUrl(tab.url)) { + throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); + } + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: contentDispatch, + args: [funcName, args], + }); + return results[0]?.result; +} + +// This function is serialized and injected into the page by chrome.scripting +function contentDispatch(funcName, args) { + function domQuery({ selector }) { + return Array.from(document.querySelectorAll(selector)).map(el => ({ + tag: el.tagName.toLowerCase(), + text: el.textContent.trim().slice(0, 200), + attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])), + })); + } + function domClick({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.click(); + return true; + } + function domType({ selector, text }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + el.focus(); + el.value = text; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + function domAttr({ selector, attr }) { + return Array.from(document.querySelectorAll(selector)) + .map(el => el.getAttribute(attr)) + .filter(v => v !== null); + } + function domText({ selector }) { + return Array.from(document.querySelectorAll(selector)) + .map(el => el.textContent.trim()) + .filter(Boolean); + } + function domExists({ selector }) { + return document.querySelector(selector) !== null; + } + function extractLinks() { + return Array.from(document.querySelectorAll("a[href]")).map(a => ({ + text: a.textContent.trim().slice(0, 100), + href: a.href, + })); + } + function extractImages() { + return Array.from(document.querySelectorAll("img")).map(img => ({ + alt: img.alt, + src: img.src, + })); + } + function extractText() { + return document.body.innerText; + } + function extractJson({ selector }) { + const el = document.querySelector(selector); + if (!el) throw new Error(`No element: ${selector}`); + return JSON.parse(el.textContent); + } + + const fns = { domQuery, domClick, domType, domAttr, domText, domExists, + extractLinks, extractImages, extractText, extractJson }; + const fn = fns[funcName]; + if (!fn) throw new Error(`Unknown content function: ${funcName}`); + return fn(args); +} + +// ── Session ─────────────────────────────────────────────────────────────────── + +async function sessionSave({ name }) { + const tabs = await chrome.tabs.query({}); + const urls = tabs.map(t => t.url).filter(Boolean); + const sessions = await getSessions(); + sessions[name] = { urls, savedAt: Date.now() }; + await chrome.storage.local.set({ sessions }); + return { name, tabs: urls.length }; +} + +async function sessionLoad({ name }) { + const sessions = await getSessions(); + const session = sessions[name]; + if (!session) throw new Error(`Session '${name}' not found`); + for (const url of session.urls) { + await chrome.tabs.create({ url, active: false }); + } + return { name, tabs: session.urls.length }; +} + +async function sessionList() { + const sessions = await getSessions(); + return Object.entries(sessions).map(([name, s]) => ({ + name, + tabs: (s.urls || []).length, + savedAt: s.savedAt || null, + })); +} + +async function sessionRemove({ name }) { + const sessions = await getSessions(); + if (!(name in sessions)) throw new Error(`Session '${name}' not found`); + delete sessions[name]; + await chrome.storage.local.set({ sessions }); + return { name }; +} + +async function sessionDiff({ nameA, nameB }) { + const sessions = await getSessions(); + const a = new Set((sessions[nameA]?.urls) || []); + const b = new Set((sessions[nameB]?.urls) || []); + return { + added: [...b].filter(u => !a.has(u)), + removed: [...a].filter(u => !b.has(u)), + }; +} + +async function sessionAutoSave({ enabled }) { + await chrome.storage.local.set({ autoSave: enabled }); + if (enabled) { + chrome.tabs.onUpdated.addListener(autoSaveHandler); + chrome.tabs.onRemoved.addListener(autoSaveHandler); + } + return { enabled }; +} + +async function autoSaveHandler() { + const { autoSave } = await chrome.storage.local.get("autoSave"); + if (!autoSave) return; + await sessionSave({ name: "__auto__" }); +} + +// ── Misc ────────────────────────────────────────────────────────────────────── + +async function clientsList() { + const manifest = chrome.runtime.getManifest(); + return [{ + name: "Chrome", + version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", + platform: navigator.platform, + extensionVersion: manifest.version, + }]; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + if (!tab) throw new Error("No active tab found"); + return tab; +} + +async function resolveGroupId(nameOrId) { + const asInt = parseInt(nameOrId); + if (!isNaN(asInt)) return asInt; + const groups = await chrome.tabGroups.query({}); + const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase()); + if (!match) throw new Error(`No tab group found with name '${nameOrId}'`); + return match.id; +} + +async function getAliases() { + const { windowAliases } = await chrome.storage.local.get("windowAliases"); + return windowAliases || {}; +} + +async function getSessions() { + const { sessions } = await chrome.storage.local.get("sessions"); + return sessions || {}; +} diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..09ab5d0 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,9 @@ +/** + * content.js — Standalone content script (optional). + * + * Most DOM operations are performed via chrome.scripting.executeScript() + * with inline functions in background.js (no persistent content script needed). + * + * This file is kept as a placeholder for future use cases that require + * a persistent content script (e.g., MutationObserver-based watching). + */ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..1f5bec7 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 3, + "name": "browser-cli", + "version": "0.1.0", + "description": "Control your browser from the terminal via browser-cli", + "permissions": [ + "tabs", + "tabGroups", + "scripting", + "windows", + "storage", + "alarms", + "nativeMessaging" + ], + "host_permissions": [""], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "browser-cli" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92f2083 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "browser-cli" +version = "0.1.0" +description = "Control your real running browser from the terminal via a Chrome extension" +requires-python = ">=3.10" +dependencies = [ + "click>=8", + "rich>=13", +] + +[project.scripts] +browser-cli = "browser_cli.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["browser_cli"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..afd4892 --- /dev/null +++ b/uv.lock @@ -0,0 +1,82 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "browser-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "rich" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8" }, + { name = "rich", specifier = ">=13" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +]