init commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -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.
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec "/tmp/browser-cli/.venv/bin/python3" "/tmp/browser-cli/browser_cli/native_host.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", {})
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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})")
|
||||||
@@ -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}")
|
||||||
@@ -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]")
|
||||||
@@ -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]")
|
||||||
@@ -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 ""))
|
||||||
@@ -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()
|
||||||
@@ -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/"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
Executable
+88
@@ -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!"
|
||||||
@@ -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 || {};
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
*/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user