init commit

This commit is contained in:
2026-04-08 21:17:59 +02:00
commit 178b7bf7a2
24 changed files with 2466 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
+1
View File
@@ -0,0 +1 @@
3.13
+334
View File
@@ -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 <repo>
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 <command>`.
### 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 <a href> links on the page
browser-cli extract images # all <img> 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.
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec "/tmp/browser-cli/.venv/bin/python3" "/tmp/browser-cli/browser_cli/native_host.py" "$@"
+168
View File
@@ -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", {})
+154
View File
@@ -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()
+61
View File
@@ -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("<I", len(payload)) + payload
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(SOCKET_PATH)
sock.sendall(framed)
response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError):
raise BrowserNotConnected(
"Cannot connect to browser.\n"
"Make sure:\n"
" 1. The browser-cli extension is installed and enabled\n"
" 2. The native host is registered: uv run browser-cli install chrome\n"
" 3. Your browser is running"
)
result = json.loads(response)
if not result.get("success", True):
raise RuntimeError(result.get("error", "unknown error from browser"))
return result.get("data")
def _recv_all(sock: socket.socket) -> bytes:
raw_len = _recv_exact(sock, 4)
msg_len = struct.unpack("<I", raw_len)[0]
return _recv_exact(sock, msg_len)
def _recv_exact(sock: socket.socket, n: int) -> 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
View File
+89
View File
@@ -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)
+68
View File
@@ -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))
+102
View File
@@ -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})")
+75
View File
@@ -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}")
+103
View File
@@ -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]")
+138
View File
@@ -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]")
+77
View File
@@ -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 ""))
+159
View File
@@ -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("<I", raw_len)[0]
data = stream.read(msg_len)
if len(data) < msg_len:
return None
return json.loads(data.decode("utf-8"))
def write_native_message(stream, msg: dict) -> None:
data = json.dumps(msg).encode("utf-8")
stream.write(struct.pack("<I", len(data)))
stream.write(data)
stream.flush()
# --- Thread A: read messages from extension (stdin) ---
def stdin_reader():
stdin = sys.stdin.buffer
while True:
msg = read_native_message(stdin)
if msg is None:
# Extension disconnected — clean up socket and exit
_cleanup()
os._exit(0)
msg_id = msg.get("id")
if msg_id:
with PENDING_LOCK:
q = PENDING.get(msg_id)
if q:
q.put(msg)
# --- Thread B: accept CLI socket connections ---
def socket_server():
path = Path(SOCKET_PATH)
if path.exists():
path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(SOCKET_PATH)
sock.listen(16)
while True:
try:
conn, _ = sock.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn,), daemon=True).start()
def handle_cli_connection(conn: socket.socket) -> 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("<I", len(data)) + data
conn.sendall(framed)
def _recv_all(conn: socket.socket) -> bytes | None:
raw_len = _recv_exact(conn, 4)
if raw_len is None:
return None
msg_len = struct.unpack("<I", raw_len)[0]
return _recv_exact(conn, msg_len)
def _recv_exact(conn: socket.socket, n: int) -> 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()
+9
View File
@@ -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/"
]
}
+107
View File
@@ -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" <h1> {h}")
else:
print(" No <h1> 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!")
+88
View File
@@ -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!"
+596
View File
@@ -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 || {};
}
+9
View File
@@ -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).
*/
+22
View File
@@ -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": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "browser-cli"
}
}
+19
View File
@@ -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"]
Generated
+82
View File
@@ -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" },
]