browser-ctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browser_ctl/SKILL.md +170 -0
- browser_ctl/__init__.py +0 -0
- browser_ctl/__main__.py +4 -0
- browser_ctl/cli.py +496 -0
- browser_ctl/server.py +275 -0
- browser_ctl-0.1.0.dist-info/METADATA +286 -0
- browser_ctl-0.1.0.dist-info/RECORD +11 -0
- browser_ctl-0.1.0.dist-info/WHEEL +5 -0
- browser_ctl-0.1.0.dist-info/entry_points.txt +3 -0
- browser_ctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- browser_ctl-0.1.0.dist-info/top_level.txt +1 -0
browser_ctl/SKILL.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# browser-ctl
|
|
2
|
+
|
|
3
|
+
CLI tool for browser automation. Control Chrome from the terminal via `bctl` commands.
|
|
4
|
+
All commands communicate through a Chrome extension + WebSocket bridge and return JSON.
|
|
5
|
+
|
|
6
|
+
## When to Use
|
|
7
|
+
|
|
8
|
+
Use browser-ctl when you need to:
|
|
9
|
+
- Navigate web pages, click elements, type text, press keys
|
|
10
|
+
- Query the DOM: get text, HTML, attributes, or count elements
|
|
11
|
+
- Take screenshots or download files (preserves browser auth/cookies)
|
|
12
|
+
- Execute arbitrary JavaScript in the page context
|
|
13
|
+
- Manage browser tabs (list, switch, open, close)
|
|
14
|
+
- Automate browser workflows for testing or data extraction
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Chrome with the Browser-Ctl extension loaded
|
|
19
|
+
- Bridge server (auto-starts with any `bctl` command)
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
### Navigation
|
|
24
|
+
```
|
|
25
|
+
bctl navigate <url> Navigate to URL (aliases: nav, go)
|
|
26
|
+
bctl back Go back
|
|
27
|
+
bctl forward Go forward (alias: fwd)
|
|
28
|
+
bctl reload Reload page
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Interaction
|
|
32
|
+
```
|
|
33
|
+
bctl click <sel> [-i N] Click element (CSS selector, optional index)
|
|
34
|
+
bctl hover <sel> [-i N] Hover over element
|
|
35
|
+
bctl type <sel> <text> Type text into element
|
|
36
|
+
bctl press <key> Press key (Enter, Escape, Tab, etc.)
|
|
37
|
+
bctl scroll <dir|sel> [n] Scroll page: up/down/top/bottom or element into view
|
|
38
|
+
bctl select-option <sel> <val> [--text] Select <select> dropdown option (alias: sopt)
|
|
39
|
+
bctl drag <src> [target] Drag element to target [--dx N --dy N for offset]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Query
|
|
43
|
+
```
|
|
44
|
+
bctl text [sel] Get text content (default: body)
|
|
45
|
+
bctl html [sel] Get innerHTML
|
|
46
|
+
bctl attr <sel> [name] Get attribute(s) [-i N for Nth element]
|
|
47
|
+
bctl select <sel> [-l N] List matching elements (alias: sel, limit default: 20)
|
|
48
|
+
bctl count <sel> Count matching elements
|
|
49
|
+
bctl status Current page URL and title
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### JavaScript
|
|
53
|
+
```
|
|
54
|
+
bctl eval <code> Execute JS in page context
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Tabs
|
|
58
|
+
```
|
|
59
|
+
bctl tabs List all tabs
|
|
60
|
+
bctl tab <id> Switch to tab
|
|
61
|
+
bctl new-tab [url] Open new tab
|
|
62
|
+
bctl close-tab [id] Close tab (default: active)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Screenshot & Download
|
|
66
|
+
```
|
|
67
|
+
bctl screenshot [path] Capture screenshot (alias: ss)
|
|
68
|
+
bctl download <target> Download file/image (alias: dl) [-o file] [-i N]
|
|
69
|
+
bctl upload <sel> <files> Upload file(s) to <input type="file">
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Wait
|
|
73
|
+
```
|
|
74
|
+
bctl wait <sel|seconds> Wait for element or sleep [timeout]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Dialog
|
|
78
|
+
```
|
|
79
|
+
bctl dialog [accept|dismiss] [--text <val>] Handle next alert/confirm/prompt
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Server
|
|
83
|
+
```
|
|
84
|
+
bctl ping Check server and extension status
|
|
85
|
+
bctl serve Start server (foreground)
|
|
86
|
+
bctl stop Stop server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Output Format
|
|
90
|
+
|
|
91
|
+
All commands return JSON:
|
|
92
|
+
- Success: `{"success": true, "data": {...}}`
|
|
93
|
+
- Error: `{"success": false, "error": "..."}`
|
|
94
|
+
|
|
95
|
+
## Tips & Best Practices
|
|
96
|
+
|
|
97
|
+
### Data Extraction
|
|
98
|
+
- **Prefer `bctl select` over `bctl eval`** for extracting structured DOM data — it's
|
|
99
|
+
more reliable across all sites, returns text/href/id/class/aria-label automatically,
|
|
100
|
+
and doesn't require complex JS strings.
|
|
101
|
+
- Use `bctl text <sel>` for simple text extraction and `bctl attr <sel> [name]` for
|
|
102
|
+
specific attributes. Chain with `-i N` for Nth element.
|
|
103
|
+
- Reserve `bctl eval` for cases that truly need complex JS logic (e.g. mapping/filtering,
|
|
104
|
+
accessing page-defined variables, or computing derived values).
|
|
105
|
+
|
|
106
|
+
### Search & Scrape Workflow
|
|
107
|
+
A typical pattern for searching a site and extracting results:
|
|
108
|
+
```bash
|
|
109
|
+
bctl go "https://site.com/search?q=keyword" # Navigate
|
|
110
|
+
bctl wait ".results" 10 # Wait for results
|
|
111
|
+
bctl select ".result-item a" -l 10 # Extract links
|
|
112
|
+
bctl attr ".result-item a" href -i 0 # Get specific attribute
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Waiting Strategy
|
|
116
|
+
- Always `bctl wait <selector> [timeout]` or `bctl wait <seconds>` after navigation
|
|
117
|
+
before querying — SPAs like YouTube take time to render content.
|
|
118
|
+
- Prefer waiting for a specific element over a fixed delay when possible.
|
|
119
|
+
|
|
120
|
+
### Shell Quoting
|
|
121
|
+
- Wrap CSS selectors in double quotes: `bctl click "button.submit"`
|
|
122
|
+
- For `bctl eval`, use double quotes for the outer string and single quotes inside:
|
|
123
|
+
`bctl eval "document.querySelector('h1').textContent"`
|
|
124
|
+
|
|
125
|
+
## Examples
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Navigate and inspect
|
|
129
|
+
bctl go https://example.com
|
|
130
|
+
bctl status
|
|
131
|
+
bctl text h1
|
|
132
|
+
|
|
133
|
+
# Click and type
|
|
134
|
+
bctl click "button.login"
|
|
135
|
+
bctl type "input[name=q]" "search query"
|
|
136
|
+
bctl press Enter
|
|
137
|
+
|
|
138
|
+
# Scroll a long page
|
|
139
|
+
bctl scroll down # Scroll down ~80% viewport
|
|
140
|
+
bctl scroll down 500 # Scroll down 500px
|
|
141
|
+
bctl scroll up # Scroll up
|
|
142
|
+
bctl scroll top # Scroll to top
|
|
143
|
+
bctl scroll bottom # Scroll to bottom
|
|
144
|
+
bctl scroll "#section-3" # Scroll element into view
|
|
145
|
+
|
|
146
|
+
# Form interaction
|
|
147
|
+
bctl select-option "select#country" "US" # Select by value
|
|
148
|
+
bctl select-option "select#lang" "English" --text # Select by visible text
|
|
149
|
+
bctl upload "input[type=file]" ./photo.jpg # Upload file
|
|
150
|
+
|
|
151
|
+
# Handle dialogs (call BEFORE triggering action)
|
|
152
|
+
bctl dialog accept # Auto-accept next alert/confirm
|
|
153
|
+
bctl dialog dismiss # Dismiss next confirm
|
|
154
|
+
bctl dialog accept --text "yes" # Answer next prompt with "yes"
|
|
155
|
+
|
|
156
|
+
# Drag and drop
|
|
157
|
+
bctl drag ".card-1" ".column-done" # Drag to target element
|
|
158
|
+
bctl drag ".slider-handle" --dx 100 --dy 0 # Drag by pixel offset
|
|
159
|
+
|
|
160
|
+
# Wait then screenshot
|
|
161
|
+
bctl wait ".loaded" 10
|
|
162
|
+
bctl ss page.png
|
|
163
|
+
|
|
164
|
+
# Download with browser auth
|
|
165
|
+
bctl download "https://site.com/file.pdf" -o file.pdf
|
|
166
|
+
|
|
167
|
+
# Extract structured data (prefer select over eval)
|
|
168
|
+
bctl select "a.video-link" -l 10
|
|
169
|
+
bctl eval "JSON.stringify(Array.from(document.querySelectorAll('a')).slice(0,5).map(a=>({text:a.textContent.trim(),href:a.href})))"
|
|
170
|
+
```
|
browser_ctl/__init__.py
ADDED
|
File without changes
|
browser_ctl/__main__.py
ADDED
browser_ctl/cli.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""browser-ctl CLI — control your browser from the terminal.
|
|
3
|
+
|
|
4
|
+
Zero external dependencies (stdlib only). Communicates with the bridge server
|
|
5
|
+
via HTTP POST to localhost:19876/command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
|
|
24
|
+
DEFAULT_PORT = 19876
|
|
25
|
+
SERVER_URL = f"http://127.0.0.1:{DEFAULT_PORT}"
|
|
26
|
+
PID_FILE = os.path.join(tempfile.gettempdir(), f"bctl-{DEFAULT_PORT}.pid")
|
|
27
|
+
|
|
28
|
+
BCTL_HOME = os.path.join(os.path.expanduser("~"), ".browser-ctl")
|
|
29
|
+
|
|
30
|
+
SKILL_TARGETS = {
|
|
31
|
+
"cursor": os.path.join(os.path.expanduser("~"), ".cursor", "skills-cursor"),
|
|
32
|
+
"opencode": os.path.join(os.path.expanduser("~"), ".config", "opencode", "skills"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Server management
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_server_running() -> bool:
|
|
41
|
+
"""Check if bridge server is running."""
|
|
42
|
+
if not os.path.exists(PID_FILE):
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
with open(PID_FILE) as f:
|
|
46
|
+
pid = int(f.read().strip())
|
|
47
|
+
os.kill(pid, 0)
|
|
48
|
+
return True
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def start_server() -> bool:
|
|
54
|
+
"""Start bridge server as daemon. Returns True if started."""
|
|
55
|
+
if is_server_running():
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
cmd = [sys.executable, "-m", "browser_ctl.server", "--port", str(DEFAULT_PORT), "--daemon"]
|
|
59
|
+
subprocess.Popen(
|
|
60
|
+
cmd,
|
|
61
|
+
start_new_session=True,
|
|
62
|
+
stdout=subprocess.DEVNULL,
|
|
63
|
+
stderr=subprocess.DEVNULL,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Wait for server to become responsive
|
|
67
|
+
for _ in range(60): # 3 seconds max
|
|
68
|
+
time.sleep(0.05)
|
|
69
|
+
try:
|
|
70
|
+
req = urllib.request.Request(f"{SERVER_URL}/health")
|
|
71
|
+
resp = urllib.request.urlopen(req, timeout=0.5)
|
|
72
|
+
if resp.status == 200:
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
print(json.dumps({"success": False, "error": "Failed to start bridge server"}))
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def stop_server():
|
|
82
|
+
"""Stop bridge server."""
|
|
83
|
+
send_raw("shutdown", {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_server():
|
|
87
|
+
"""Make sure server is running, start if needed."""
|
|
88
|
+
if not is_server_running():
|
|
89
|
+
start_server()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Command sending
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def send_raw(action: str, params: dict) -> dict:
|
|
98
|
+
"""Send command to bridge server, return parsed response."""
|
|
99
|
+
body = json.dumps({"action": action, "params": params}).encode("utf-8")
|
|
100
|
+
req = urllib.request.Request(
|
|
101
|
+
f"{SERVER_URL}/command",
|
|
102
|
+
data=body,
|
|
103
|
+
headers={"Content-Type": "application/json"},
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
resp = urllib.request.urlopen(req, timeout=35)
|
|
107
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
108
|
+
except urllib.error.URLError as e:
|
|
109
|
+
return {"success": False, "error": f"Cannot connect to server: {e}"}
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
return {"success": False, "error": "Invalid response from server"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def send_command(action: str, params: dict):
|
|
115
|
+
"""Ensure server, send command, print JSON result."""
|
|
116
|
+
ensure_server()
|
|
117
|
+
result = send_raw(action, params)
|
|
118
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
119
|
+
if not result.get("success"):
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# CLI definition
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
prog="bctl",
|
|
131
|
+
description="Control your browser from the command line",
|
|
132
|
+
)
|
|
133
|
+
sub = parser.add_subparsers(dest="command", help="Available commands")
|
|
134
|
+
|
|
135
|
+
# -- Navigation --
|
|
136
|
+
p = sub.add_parser("navigate", aliases=["nav", "go"], help="Navigate to URL")
|
|
137
|
+
p.add_argument("url", help="URL to navigate to")
|
|
138
|
+
|
|
139
|
+
sub.add_parser("back", help="Go back in history")
|
|
140
|
+
sub.add_parser("forward", aliases=["fwd"], help="Go forward in history")
|
|
141
|
+
sub.add_parser("reload", help="Reload current page")
|
|
142
|
+
|
|
143
|
+
# -- Interaction --
|
|
144
|
+
p = sub.add_parser("click", help="Click an element")
|
|
145
|
+
p.add_argument("selector", help="CSS selector")
|
|
146
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Click Nth matching element (0-based, negative from end)")
|
|
147
|
+
|
|
148
|
+
p = sub.add_parser("hover", help="Hover over an element (trigger mouseover)")
|
|
149
|
+
p.add_argument("selector", help="CSS selector")
|
|
150
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Hover Nth matching element (0-based)")
|
|
151
|
+
|
|
152
|
+
p = sub.add_parser("type", help="Type text into an element")
|
|
153
|
+
p.add_argument("selector", help="CSS selector")
|
|
154
|
+
p.add_argument("text", help="Text to type")
|
|
155
|
+
|
|
156
|
+
p = sub.add_parser("press", help="Press a keyboard key")
|
|
157
|
+
p.add_argument("key", help="Key name (Enter, Escape, Tab, etc.)")
|
|
158
|
+
|
|
159
|
+
# -- Query --
|
|
160
|
+
p = sub.add_parser("text", help="Get text content of an element")
|
|
161
|
+
p.add_argument("selector", nargs="?", default=None, help="CSS selector (default: body)")
|
|
162
|
+
|
|
163
|
+
p = sub.add_parser("html", help="Get innerHTML of an element")
|
|
164
|
+
p.add_argument("selector", nargs="?", default=None, help="CSS selector (default: body)")
|
|
165
|
+
|
|
166
|
+
p = sub.add_parser("attr", help="Get attribute(s) of an element")
|
|
167
|
+
p.add_argument("selector", help="CSS selector")
|
|
168
|
+
p.add_argument("name", nargs="?", default=None, help="Attribute name (omit for all)")
|
|
169
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Get Nth matching element (0-based)")
|
|
170
|
+
|
|
171
|
+
p = sub.add_parser("select", aliases=["sel"], help="Query all matching elements (returns summary of each)")
|
|
172
|
+
p.add_argument("selector", help="CSS selector")
|
|
173
|
+
p.add_argument("-l", "--limit", type=int, default=20, help="Max items to return (default: 20)")
|
|
174
|
+
|
|
175
|
+
p = sub.add_parser("count", help="Count matching elements")
|
|
176
|
+
p.add_argument("selector", help="CSS selector")
|
|
177
|
+
|
|
178
|
+
sub.add_parser("status", help="Get current page URL and title")
|
|
179
|
+
|
|
180
|
+
# -- JavaScript --
|
|
181
|
+
p = sub.add_parser("eval", help="Execute JavaScript in page context")
|
|
182
|
+
p.add_argument("code", help="JavaScript code to execute")
|
|
183
|
+
|
|
184
|
+
# -- Tabs --
|
|
185
|
+
sub.add_parser("tabs", help="List all open tabs")
|
|
186
|
+
|
|
187
|
+
p = sub.add_parser("tab", help="Switch to a tab by ID")
|
|
188
|
+
p.add_argument("id", type=int, help="Tab ID")
|
|
189
|
+
|
|
190
|
+
p = sub.add_parser("new-tab", help="Open a new tab")
|
|
191
|
+
p.add_argument("url", nargs="?", default=None, help="URL to open")
|
|
192
|
+
|
|
193
|
+
p = sub.add_parser("close-tab", help="Close a tab")
|
|
194
|
+
p.add_argument("id", nargs="?", type=int, default=None, help="Tab ID (default: active)")
|
|
195
|
+
|
|
196
|
+
# -- Screenshot / Download --
|
|
197
|
+
p = sub.add_parser("screenshot", aliases=["ss"], help="Capture screenshot")
|
|
198
|
+
p.add_argument("path", nargs="?", default=None, help="Save to file path (default: print base64)")
|
|
199
|
+
|
|
200
|
+
p = sub.add_parser("download", aliases=["dl"], help="Download a file/image using browser's auth session")
|
|
201
|
+
p.add_argument("target", help="URL or CSS selector of an element (img, a, etc.)")
|
|
202
|
+
p.add_argument("-o", "--output", default=None, help="Output filename (default: auto)")
|
|
203
|
+
p.add_argument("-i", "--index", type=int, default=None, help="Download Nth matching element (0-based, negative from end)")
|
|
204
|
+
|
|
205
|
+
# -- Scroll --
|
|
206
|
+
p = sub.add_parser("scroll", help="Scroll the page")
|
|
207
|
+
p.add_argument("target", help="Direction (up/down/top/bottom) or CSS selector to scroll into view")
|
|
208
|
+
p.add_argument("amount", nargs="?", type=int, default=None, help="Pixels to scroll (for up/down)")
|
|
209
|
+
|
|
210
|
+
# -- Form interaction --
|
|
211
|
+
p = sub.add_parser("select-option", aliases=["sopt"], help="Select option in <select> dropdown")
|
|
212
|
+
p.add_argument("selector", help="CSS selector for <select> element")
|
|
213
|
+
p.add_argument("value", help="Option value or text to select")
|
|
214
|
+
p.add_argument("--text", action="store_true", help="Match by visible text instead of value")
|
|
215
|
+
|
|
216
|
+
# -- File upload --
|
|
217
|
+
p = sub.add_parser("upload", help="Upload file(s) to file input")
|
|
218
|
+
p.add_argument("selector", help="CSS selector for file input")
|
|
219
|
+
p.add_argument("files", nargs="+", help="File path(s) to upload")
|
|
220
|
+
|
|
221
|
+
# -- Dialog --
|
|
222
|
+
p = sub.add_parser("dialog", help="Set handler for next browser dialog (alert/confirm/prompt)")
|
|
223
|
+
p.add_argument("action", nargs="?", default="accept", choices=["accept", "dismiss"], help="Accept or dismiss (default: accept)")
|
|
224
|
+
p.add_argument("--text", default=None, help="Response text for prompt dialog")
|
|
225
|
+
|
|
226
|
+
# -- Drag --
|
|
227
|
+
p = sub.add_parser("drag", help="Drag element to another element or offset")
|
|
228
|
+
p.add_argument("source", help="CSS selector of element to drag")
|
|
229
|
+
p.add_argument("target", nargs="?", default=None, help="CSS selector of drop target")
|
|
230
|
+
p.add_argument("--dx", type=int, default=None, help="Horizontal pixel offset (when no target)")
|
|
231
|
+
p.add_argument("--dy", type=int, default=None, help="Vertical pixel offset (when no target)")
|
|
232
|
+
|
|
233
|
+
# -- Wait --
|
|
234
|
+
p = sub.add_parser("wait", help="Wait for element or sleep")
|
|
235
|
+
p.add_argument("target", help="CSS selector or seconds to wait")
|
|
236
|
+
p.add_argument("timeout", nargs="?", type=float, default=5, help="Timeout in seconds (default: 5)")
|
|
237
|
+
|
|
238
|
+
# -- Server management --
|
|
239
|
+
sub.add_parser("serve", help="Start bridge server (foreground)")
|
|
240
|
+
sub.add_parser("ping", help="Check server and extension status")
|
|
241
|
+
sub.add_parser("stop", help="Stop bridge server")
|
|
242
|
+
|
|
243
|
+
# -- Setup --
|
|
244
|
+
p = sub.add_parser("setup", help="Install Chrome extension and AI coding skill")
|
|
245
|
+
p.add_argument(
|
|
246
|
+
"target",
|
|
247
|
+
nargs="?",
|
|
248
|
+
default=None,
|
|
249
|
+
help="Skill target: cursor, opencode, or a custom directory path",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return parser
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Command handlers
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def handle_screenshot(args):
|
|
261
|
+
"""Screenshot needs special handling for file save."""
|
|
262
|
+
ensure_server()
|
|
263
|
+
result = send_raw("screenshot", {})
|
|
264
|
+
if not result.get("success"):
|
|
265
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
if args.path:
|
|
269
|
+
# Save to file
|
|
270
|
+
b64 = result["data"]["base64"]
|
|
271
|
+
img_bytes = base64.b64decode(b64)
|
|
272
|
+
with open(args.path, "wb") as f:
|
|
273
|
+
f.write(img_bytes)
|
|
274
|
+
print(json.dumps({"success": True, "data": {"saved": args.path, "bytes": len(img_bytes)}}))
|
|
275
|
+
else:
|
|
276
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def handle_serve(args):
|
|
280
|
+
"""Run server in foreground."""
|
|
281
|
+
os.execvp(sys.executable, [sys.executable, "-m", "browser_ctl.server", "--port", str(DEFAULT_PORT)])
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _get_extension_source_dir() -> str | None:
|
|
285
|
+
"""Locate the extension source directory (from project root)."""
|
|
286
|
+
pkg_dir = os.path.dirname(os.path.abspath(__file__))
|
|
287
|
+
project_root = os.path.dirname(pkg_dir)
|
|
288
|
+
ext_dir = os.path.join(project_root, "extension")
|
|
289
|
+
if os.path.isdir(ext_dir) and os.path.exists(os.path.join(ext_dir, "manifest.json")):
|
|
290
|
+
return ext_dir
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _install_extension() -> str | None:
|
|
295
|
+
"""Copy extension to ~/.browser-ctl/extension/ and try to open Chrome extensions page."""
|
|
296
|
+
src = _get_extension_source_dir()
|
|
297
|
+
if not src:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
dest = os.path.join(BCTL_HOME, "extension")
|
|
301
|
+
if os.path.exists(dest):
|
|
302
|
+
shutil.rmtree(dest)
|
|
303
|
+
shutil.copytree(src, dest)
|
|
304
|
+
|
|
305
|
+
# Try to open Chrome extensions page
|
|
306
|
+
system = platform.system()
|
|
307
|
+
try:
|
|
308
|
+
if system == "Darwin":
|
|
309
|
+
subprocess.Popen(
|
|
310
|
+
["open", "-a", "Google Chrome", "chrome://extensions"],
|
|
311
|
+
stdout=subprocess.DEVNULL,
|
|
312
|
+
stderr=subprocess.DEVNULL,
|
|
313
|
+
)
|
|
314
|
+
elif system == "Linux":
|
|
315
|
+
for cmd in ["google-chrome", "chromium", "chromium-browser"]:
|
|
316
|
+
try:
|
|
317
|
+
subprocess.Popen(
|
|
318
|
+
[cmd, "chrome://extensions"],
|
|
319
|
+
stdout=subprocess.DEVNULL,
|
|
320
|
+
stderr=subprocess.DEVNULL,
|
|
321
|
+
)
|
|
322
|
+
break
|
|
323
|
+
except FileNotFoundError:
|
|
324
|
+
continue
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
return dest
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _install_skill(target_dir: str) -> str:
|
|
332
|
+
"""Copy SKILL.md into <target_dir>/browser-ctl/."""
|
|
333
|
+
src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "SKILL.md")
|
|
334
|
+
if not os.path.isfile(src):
|
|
335
|
+
raise FileNotFoundError("SKILL.md not found in browser_ctl package.")
|
|
336
|
+
|
|
337
|
+
skill_dir = os.path.join(target_dir, "browser-ctl")
|
|
338
|
+
os.makedirs(skill_dir, exist_ok=True)
|
|
339
|
+
skill_path = os.path.join(skill_dir, "SKILL.md")
|
|
340
|
+
# Remove existing file/symlink before copying
|
|
341
|
+
if os.path.lexists(skill_path):
|
|
342
|
+
os.remove(skill_path)
|
|
343
|
+
shutil.copy2(src, skill_path)
|
|
344
|
+
return skill_path
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def handle_setup(args):
|
|
348
|
+
"""Install Chrome extension and/or AI coding skill."""
|
|
349
|
+
print("browser-ctl setup")
|
|
350
|
+
print("=" * 40)
|
|
351
|
+
|
|
352
|
+
# --- Extension ---
|
|
353
|
+
ext_dir = _install_extension()
|
|
354
|
+
if ext_dir:
|
|
355
|
+
print(f"\n[extension] installed -> {ext_dir}")
|
|
356
|
+
print()
|
|
357
|
+
print(" Load in Chrome:")
|
|
358
|
+
print(" 1. Open chrome://extensions")
|
|
359
|
+
print(" 2. Enable 'Developer mode' (top right)")
|
|
360
|
+
print(" 3. Click 'Load unpacked'")
|
|
361
|
+
print(f" 4. Select: {ext_dir}")
|
|
362
|
+
else:
|
|
363
|
+
print("\n[extension] source not found")
|
|
364
|
+
print(" Make sure you are running from a source checkout or dev install.")
|
|
365
|
+
|
|
366
|
+
# --- Skill ---
|
|
367
|
+
if args.target:
|
|
368
|
+
target = args.target
|
|
369
|
+
if target in SKILL_TARGETS:
|
|
370
|
+
target_dir = SKILL_TARGETS[target]
|
|
371
|
+
label = target
|
|
372
|
+
else:
|
|
373
|
+
target_dir = os.path.expanduser(target)
|
|
374
|
+
label = target_dir
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
skill_path = _install_skill(target_dir)
|
|
378
|
+
print(f"\n[skill] installed ({label}) -> {skill_path}")
|
|
379
|
+
except FileNotFoundError as e:
|
|
380
|
+
print(f"\n[skill] error: {e}")
|
|
381
|
+
else:
|
|
382
|
+
print("\n[skill] skipped (no target specified)")
|
|
383
|
+
print()
|
|
384
|
+
print(" Available targets:")
|
|
385
|
+
for name, path in SKILL_TARGETS.items():
|
|
386
|
+
print(f" bctl setup {name:10s} -> {path}/browser-ctl/SKILL.md")
|
|
387
|
+
print(f" bctl setup <path> -> <path>/browser-ctl/SKILL.md")
|
|
388
|
+
|
|
389
|
+
print()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
# Main
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main():
|
|
398
|
+
parser = build_parser()
|
|
399
|
+
args = parser.parse_args()
|
|
400
|
+
|
|
401
|
+
if not args.command:
|
|
402
|
+
parser.print_help()
|
|
403
|
+
sys.exit(0)
|
|
404
|
+
|
|
405
|
+
cmd = args.command
|
|
406
|
+
|
|
407
|
+
# Aliases
|
|
408
|
+
if cmd in ("nav", "go"):
|
|
409
|
+
cmd = "navigate"
|
|
410
|
+
if cmd == "fwd":
|
|
411
|
+
cmd = "forward"
|
|
412
|
+
if cmd in ("ss",):
|
|
413
|
+
cmd = "screenshot"
|
|
414
|
+
if cmd in ("sel",):
|
|
415
|
+
cmd = "select"
|
|
416
|
+
if cmd in ("dl",):
|
|
417
|
+
cmd = "download"
|
|
418
|
+
if cmd in ("sopt",):
|
|
419
|
+
cmd = "select-option"
|
|
420
|
+
|
|
421
|
+
# Local-only commands (no server needed)
|
|
422
|
+
if cmd == "setup":
|
|
423
|
+
handle_setup(args)
|
|
424
|
+
return
|
|
425
|
+
if cmd == "serve":
|
|
426
|
+
handle_serve(args)
|
|
427
|
+
return
|
|
428
|
+
if cmd == "stop":
|
|
429
|
+
stop_server()
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
# Screenshot (special handling)
|
|
433
|
+
if cmd == "screenshot":
|
|
434
|
+
handle_screenshot(args)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
# Map CLI args to command params
|
|
438
|
+
params = {}
|
|
439
|
+
if cmd == "navigate":
|
|
440
|
+
params = {"url": args.url}
|
|
441
|
+
elif cmd == "click":
|
|
442
|
+
params = {"selector": args.selector, "index": args.index}
|
|
443
|
+
elif cmd == "hover":
|
|
444
|
+
params = {"selector": args.selector, "index": args.index}
|
|
445
|
+
elif cmd == "type":
|
|
446
|
+
params = {"selector": args.selector, "text": args.text}
|
|
447
|
+
elif cmd == "press":
|
|
448
|
+
params = {"key": args.key}
|
|
449
|
+
elif cmd == "text":
|
|
450
|
+
params = {"selector": args.selector}
|
|
451
|
+
elif cmd == "html":
|
|
452
|
+
params = {"selector": args.selector}
|
|
453
|
+
elif cmd == "attr":
|
|
454
|
+
params = {"selector": args.selector, "name": args.name, "index": args.index}
|
|
455
|
+
elif cmd == "select":
|
|
456
|
+
params = {"selector": args.selector, "limit": args.limit}
|
|
457
|
+
elif cmd == "count":
|
|
458
|
+
params = {"selector": args.selector}
|
|
459
|
+
elif cmd == "eval":
|
|
460
|
+
params = {"code": args.code}
|
|
461
|
+
elif cmd == "tab":
|
|
462
|
+
params = {"id": args.id}
|
|
463
|
+
elif cmd == "new-tab":
|
|
464
|
+
params = {"url": args.url}
|
|
465
|
+
elif cmd == "close-tab":
|
|
466
|
+
params = {"id": args.id}
|
|
467
|
+
elif cmd == "download":
|
|
468
|
+
target = args.target
|
|
469
|
+
if target.startswith("http://") or target.startswith("https://"):
|
|
470
|
+
params = {"url": target, "filename": args.output}
|
|
471
|
+
else:
|
|
472
|
+
params = {"selector": target, "filename": args.output, "index": args.index}
|
|
473
|
+
elif cmd == "scroll":
|
|
474
|
+
params = {"target": args.target, "amount": args.amount}
|
|
475
|
+
elif cmd == "select-option":
|
|
476
|
+
params = {"selector": args.selector, "value": args.value, "byText": args.text}
|
|
477
|
+
elif cmd == "upload":
|
|
478
|
+
files = [os.path.abspath(f) for f in args.files]
|
|
479
|
+
params = {"selector": args.selector, "files": files}
|
|
480
|
+
elif cmd == "dialog":
|
|
481
|
+
params = {"accept": args.action == "accept", "text": args.text}
|
|
482
|
+
elif cmd == "drag":
|
|
483
|
+
params = {"source": args.source, "target": args.target, "dx": args.dx, "dy": args.dy}
|
|
484
|
+
elif cmd == "wait":
|
|
485
|
+
# Determine if target is a number (sleep) or selector
|
|
486
|
+
try:
|
|
487
|
+
seconds = float(args.target)
|
|
488
|
+
params = {"seconds": seconds}
|
|
489
|
+
except ValueError:
|
|
490
|
+
params = {"selector": args.target, "timeout": args.timeout}
|
|
491
|
+
|
|
492
|
+
send_command(cmd, params)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
if __name__ == "__main__":
|
|
496
|
+
main()
|
browser_ctl/server.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Bridge server: relays commands between CLI (HTTP) and Chrome extension (WebSocket).
|
|
2
|
+
|
|
3
|
+
Single port serves both protocols:
|
|
4
|
+
- POST /command — CLI sends commands here
|
|
5
|
+
- GET /ws — Chrome extension connects here
|
|
6
|
+
- GET /health — Health check
|
|
7
|
+
|
|
8
|
+
Commands are matched to responses via request IDs using asyncio.Future.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
from aiohttp import web, WSMsgType
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
26
|
+
)
|
|
27
|
+
log = logging.getLogger("bctl.server")
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# State
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# Currently connected extension WebSocket (only one at a time)
|
|
34
|
+
_ext_ws: web.WebSocketResponse | None = None
|
|
35
|
+
|
|
36
|
+
# Pending command futures: request_id -> Future[dict]
|
|
37
|
+
_pending: dict[str, asyncio.Future] = {}
|
|
38
|
+
|
|
39
|
+
DEFAULT_PORT = 19876
|
|
40
|
+
COMMAND_TIMEOUT = 30 # seconds
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# WebSocket handler (Chrome extension)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
|
|
47
|
+
global _ext_ws
|
|
48
|
+
|
|
49
|
+
ws = web.WebSocketResponse(heartbeat=20)
|
|
50
|
+
await ws.prepare(request)
|
|
51
|
+
log.info("Extension connected")
|
|
52
|
+
|
|
53
|
+
# Replace any stale connection
|
|
54
|
+
if _ext_ws is not None and not _ext_ws.closed:
|
|
55
|
+
await _ext_ws.close()
|
|
56
|
+
_ext_ws = ws
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
async for msg in ws:
|
|
60
|
+
if msg.type == WSMsgType.TEXT:
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(msg.data)
|
|
63
|
+
req_id = data.get("id", "")
|
|
64
|
+
if req_id in _pending:
|
|
65
|
+
_pending[req_id].set_result(data)
|
|
66
|
+
else:
|
|
67
|
+
log.warning("Response for unknown request id: %s", req_id)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
log.warning("Invalid JSON from extension: %s", msg.data[:200])
|
|
70
|
+
elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE):
|
|
71
|
+
break
|
|
72
|
+
finally:
|
|
73
|
+
log.info("Extension disconnected")
|
|
74
|
+
if _ext_ws is ws:
|
|
75
|
+
_ext_ws = None
|
|
76
|
+
|
|
77
|
+
return ws
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# HTTP handler (CLI)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def command_handler(request: web.Request) -> web.Response:
|
|
85
|
+
"""Receive a command from CLI, relay to extension, return response."""
|
|
86
|
+
try:
|
|
87
|
+
body = await request.json()
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return _json_error("Invalid JSON in request body", status=400)
|
|
90
|
+
|
|
91
|
+
action = body.get("action", "")
|
|
92
|
+
params = body.get("params", {})
|
|
93
|
+
|
|
94
|
+
# Server-local commands
|
|
95
|
+
if action == "ping":
|
|
96
|
+
return _json_ok({
|
|
97
|
+
"server": True,
|
|
98
|
+
"extension": _ext_ws is not None and not _ext_ws.closed,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if action == "shutdown":
|
|
102
|
+
log.info("Shutdown requested")
|
|
103
|
+
asyncio.get_event_loop().call_later(0.1, _shutdown)
|
|
104
|
+
return _json_ok({"shutdown": True})
|
|
105
|
+
|
|
106
|
+
# Relay to extension
|
|
107
|
+
if _ext_ws is None or _ext_ws.closed:
|
|
108
|
+
return _json_error("Chrome extension not connected. Open Chrome and check the extension is loaded.")
|
|
109
|
+
|
|
110
|
+
req_id = f"r-{uuid.uuid4().hex[:12]}"
|
|
111
|
+
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
112
|
+
_pending[req_id] = future
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await _ext_ws.send_json({
|
|
116
|
+
"id": req_id,
|
|
117
|
+
"action": action,
|
|
118
|
+
"params": params,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
# Wait for extension response
|
|
122
|
+
result = await asyncio.wait_for(future, timeout=COMMAND_TIMEOUT)
|
|
123
|
+
return web.json_response(result)
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
return _json_error(f"Extension did not respond within {COMMAND_TIMEOUT}s")
|
|
126
|
+
except ConnectionResetError:
|
|
127
|
+
return _json_error("Extension connection lost during command")
|
|
128
|
+
finally:
|
|
129
|
+
_pending.pop(req_id, None)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def health_handler(request: web.Request) -> web.Response:
|
|
133
|
+
return _json_ok({
|
|
134
|
+
"server": True,
|
|
135
|
+
"extension": _ext_ws is not None and not _ext_ws.closed,
|
|
136
|
+
"pending_commands": len(_pending),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Helpers
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _json_ok(data: dict, status: int = 200) -> web.Response:
|
|
145
|
+
return web.json_response({"success": True, "data": data}, status=status)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _json_error(error: str, status: int = 200) -> web.Response:
|
|
149
|
+
return web.json_response({"success": False, "error": error}, status=status)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_app_runner: web.AppRunner | None = None
|
|
153
|
+
|
|
154
|
+
def _shutdown():
|
|
155
|
+
"""Graceful shutdown."""
|
|
156
|
+
loop = asyncio.get_event_loop()
|
|
157
|
+
loop.call_soon(loop.stop)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# App factory & entry point
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def create_app() -> web.Application:
|
|
165
|
+
app = web.Application()
|
|
166
|
+
app.router.add_get("/ws", ws_handler)
|
|
167
|
+
app.router.add_post("/command", command_handler)
|
|
168
|
+
app.router.add_get("/health", health_handler)
|
|
169
|
+
return app
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def write_pid_file(port: int) -> str:
|
|
173
|
+
"""Write PID file so CLI can check if server is running."""
|
|
174
|
+
import tempfile
|
|
175
|
+
pid_path = os.path.join(tempfile.gettempdir(), f"bctl-{port}.pid")
|
|
176
|
+
with open(pid_path, "w") as f:
|
|
177
|
+
f.write(str(os.getpid()))
|
|
178
|
+
return pid_path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
parser = argparse.ArgumentParser(description="browser-ctl bridge server")
|
|
183
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to listen on")
|
|
184
|
+
parser.add_argument("--daemon", action="store_true", help="Run as background daemon")
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
if args.daemon:
|
|
188
|
+
_daemonize(args.port)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
pid_path = write_pid_file(args.port)
|
|
192
|
+
log.info("PID file: %s", pid_path)
|
|
193
|
+
log.info("Starting bridge server on http://localhost:%d", args.port)
|
|
194
|
+
|
|
195
|
+
def cleanup():
|
|
196
|
+
try:
|
|
197
|
+
os.unlink(pid_path)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
import atexit
|
|
202
|
+
atexit.register(cleanup)
|
|
203
|
+
|
|
204
|
+
# Handle signals
|
|
205
|
+
def handle_signal(sig, frame):
|
|
206
|
+
log.info("Received signal %s, shutting down", sig)
|
|
207
|
+
cleanup()
|
|
208
|
+
sys.exit(0)
|
|
209
|
+
|
|
210
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
211
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
212
|
+
|
|
213
|
+
app = create_app()
|
|
214
|
+
web.run_app(app, host="127.0.0.1", port=args.port, print=lambda msg: log.info(msg))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _daemonize(port: int):
|
|
218
|
+
"""Fork into background daemon (Unix only)."""
|
|
219
|
+
if sys.platform == "win32":
|
|
220
|
+
# Windows: just run directly (no fork)
|
|
221
|
+
main_args = [sys.executable, "-m", "browser_ctl.server", "--port", str(port)]
|
|
222
|
+
import subprocess
|
|
223
|
+
subprocess.Popen(
|
|
224
|
+
main_args,
|
|
225
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS,
|
|
226
|
+
stdout=subprocess.DEVNULL,
|
|
227
|
+
stderr=subprocess.DEVNULL,
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Unix double-fork
|
|
232
|
+
pid = os.fork()
|
|
233
|
+
if pid > 0:
|
|
234
|
+
# Parent: wait briefly then exit
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# Child: new session
|
|
238
|
+
os.setsid()
|
|
239
|
+
|
|
240
|
+
pid = os.fork()
|
|
241
|
+
if pid > 0:
|
|
242
|
+
os._exit(0)
|
|
243
|
+
|
|
244
|
+
# Grandchild: redirect stdio
|
|
245
|
+
sys.stdin.close()
|
|
246
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
247
|
+
os.dup2(devnull, 0)
|
|
248
|
+
|
|
249
|
+
# Redirect stdout/stderr to log file
|
|
250
|
+
import tempfile
|
|
251
|
+
log_path = os.path.join(tempfile.gettempdir(), f"bctl-{port}.log")
|
|
252
|
+
log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
253
|
+
os.dup2(log_fd, 1)
|
|
254
|
+
os.dup2(log_fd, 2)
|
|
255
|
+
|
|
256
|
+
# Now run the server
|
|
257
|
+
pid_path = write_pid_file(port)
|
|
258
|
+
log.info("Daemon started (pid=%d), log: %s", os.getpid(), log_path)
|
|
259
|
+
|
|
260
|
+
import atexit
|
|
261
|
+
atexit.register(lambda: _cleanup_pid(pid_path))
|
|
262
|
+
|
|
263
|
+
app = create_app()
|
|
264
|
+
web.run_app(app, host="127.0.0.1", port=port, print=lambda msg: log.info(msg))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cleanup_pid(pid_path: str):
|
|
268
|
+
try:
|
|
269
|
+
os.unlink(pid_path)
|
|
270
|
+
except OSError:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browser-ctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Control your browser from the command line via a Chrome extension + WebSocket bridge
|
|
5
|
+
Author-email: geb <853934146@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mikuh/browser-ctl
|
|
8
|
+
Project-URL: Repository, https://github.com/mikuh/browser-ctl
|
|
9
|
+
Project-URL: Issues, https://github.com/mikuh/browser-ctl/issues
|
|
10
|
+
Keywords: browser,automation,chrome,cli,websocket,devtools
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: aiohttp>=3.9
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# browser-ctl
|
|
28
|
+
|
|
29
|
+
**Control Chrome from your terminal.** A lightweight CLI tool for browser automation — navigate, click, type, scroll, screenshot, and more, all through simple commands.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install browser-ctl
|
|
33
|
+
|
|
34
|
+
bctl go https://github.com
|
|
35
|
+
bctl click "a.search-button"
|
|
36
|
+
bctl type "input[name=q]" "browser-ctl"
|
|
37
|
+
bctl press Enter
|
|
38
|
+
bctl screenshot results.png
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Why browser-ctl?
|
|
42
|
+
|
|
43
|
+
- **Zero-config CLI** — single `bctl` command, JSON output, works in any shell or script
|
|
44
|
+
- **No browser binary management** — uses your existing Chrome with a lightweight extension
|
|
45
|
+
- **Stdlib-only CLI** — the CLI itself has zero external Python dependencies
|
|
46
|
+
- **AI-agent friendly** — ships with an AI coding skill file (`SKILL.md`) for Cursor / OpenCode integration
|
|
47
|
+
- **Local & private** — all communication stays on `localhost`, no data leaves your machine
|
|
48
|
+
|
|
49
|
+
## How It Works
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Terminal (bctl) ──HTTP──▶ Bridge Server ◀──WebSocket── Chrome Extension
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
1. The **CLI** (`bctl`) sends commands via HTTP to a local bridge server
|
|
56
|
+
2. The **bridge server** relays them over WebSocket to the Chrome extension
|
|
57
|
+
3. The **extension** executes commands using Chrome APIs and content scripts
|
|
58
|
+
4. Results flow back the same path as JSON
|
|
59
|
+
|
|
60
|
+
The bridge server auto-starts on first command — no manual setup needed.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
### 1. Install the Python package
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install browser-ctl
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2. Load the Chrome extension
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bctl setup
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This copies the extension to `~/.browser-ctl/extension/` and opens Chrome's extension page. Then:
|
|
77
|
+
|
|
78
|
+
1. Open `chrome://extensions`
|
|
79
|
+
2. Enable **Developer mode** (top right)
|
|
80
|
+
3. Click **Load unpacked**
|
|
81
|
+
4. Select the `~/.browser-ctl/extension/` directory
|
|
82
|
+
|
|
83
|
+
### 3. Verify
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bctl ping
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You should see `{"success": true, "data": {"server": true, "extension": true}}`.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
### Navigation
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bctl navigate <url> # Navigate to URL (aliases: nav, go)
|
|
97
|
+
bctl back # Go back in history
|
|
98
|
+
bctl forward # Go forward (alias: fwd)
|
|
99
|
+
bctl reload # Reload current page
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Interaction
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bctl click <sel> [-i N] # Click element (CSS selector, optional Nth match)
|
|
106
|
+
bctl hover <sel> [-i N] # Hover over element
|
|
107
|
+
bctl type <sel> <text> # Type text into input/textarea
|
|
108
|
+
bctl press <key> # Press key (Enter, Escape, Tab, etc.)
|
|
109
|
+
bctl scroll <dir|sel> [pixels] # Scroll: up/down/top/bottom or element into view
|
|
110
|
+
bctl select-option <sel> <val> # Select dropdown option (alias: sopt) [--text]
|
|
111
|
+
bctl drag <src> [target] # Drag to element or offset [--dx N --dy N]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### DOM Query
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bctl text [sel] # Get text content (default: body)
|
|
118
|
+
bctl html [sel] # Get innerHTML
|
|
119
|
+
bctl attr <sel> [name] # Get attribute(s) [-i N for Nth element]
|
|
120
|
+
bctl select <sel> [-l N] # List matching elements (alias: sel, limit default: 20)
|
|
121
|
+
bctl count <sel> # Count matching elements
|
|
122
|
+
bctl status # Current page URL and title
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### JavaScript
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bctl eval <code> # Execute JS in page context (auto-bypasses CSP)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Tabs
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bctl tabs # List all tabs
|
|
135
|
+
bctl tab <id> # Switch to tab by ID
|
|
136
|
+
bctl new-tab [url] # Open new tab
|
|
137
|
+
bctl close-tab [id] # Close tab (default: active)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Screenshot & Files
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
bctl screenshot [path] # Capture screenshot (alias: ss)
|
|
144
|
+
bctl download <target> # Download file/image (alias: dl) [-o file] [-i N]
|
|
145
|
+
bctl upload <sel> <files> # Upload file(s) to <input type="file">
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Wait & Dialog
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
bctl wait <sel|seconds> # Wait for element or sleep [timeout]
|
|
152
|
+
bctl dialog [accept|dismiss] [--text <val>] # Handle next alert/confirm/prompt
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Server
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
bctl ping # Check server & extension status
|
|
159
|
+
bctl serve # Start server in foreground
|
|
160
|
+
bctl stop # Stop server
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
### Search and extract
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
bctl go "https://news.ycombinator.com"
|
|
169
|
+
bctl select "a.titlelink" -l 5 # Top 5 links with text, href, etc.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Fill a form
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
bctl type "input[name=email]" "user@example.com"
|
|
176
|
+
bctl type "input[name=password]" "hunter2"
|
|
177
|
+
bctl select-option "select#country" "US"
|
|
178
|
+
bctl upload "input[type=file]" ./resume.pdf
|
|
179
|
+
bctl click "button[type=submit]"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Scroll and screenshot
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
bctl go "https://en.wikipedia.org/wiki/Web_browser"
|
|
186
|
+
bctl scroll down 1000
|
|
187
|
+
bctl ss page.png
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Handle dialogs
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
bctl dialog accept # Set up handler BEFORE triggering
|
|
194
|
+
bctl click "#delete-button" # This triggers a confirm() dialog
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Drag and drop
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
bctl drag ".task-card" ".done-column"
|
|
201
|
+
bctl drag ".range-slider" --dx 50 --dy 0
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Use in shell scripts
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
# Extract all image URLs from a page
|
|
208
|
+
bctl go "https://example.com"
|
|
209
|
+
bctl eval "JSON.stringify(Array.from(document.images).map(i=>i.src))"
|
|
210
|
+
|
|
211
|
+
# Wait for SPA content to load
|
|
212
|
+
bctl go "https://app.example.com/dashboard"
|
|
213
|
+
bctl wait ".dashboard-loaded" 15
|
|
214
|
+
bctl text ".metric-value"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## AI Agent Integration
|
|
218
|
+
|
|
219
|
+
browser-ctl ships with a `SKILL.md` file designed for AI coding assistants. Install it for your tool:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
bctl setup cursor # Install skill for Cursor IDE
|
|
223
|
+
bctl setup opencode # Install skill for OpenCode
|
|
224
|
+
bctl setup /path/to/dir # Install to custom directory
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Once installed, AI agents can use `bctl` commands to automate browser tasks on your behalf.
|
|
228
|
+
|
|
229
|
+
## Output Format
|
|
230
|
+
|
|
231
|
+
All commands return JSON to stdout:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
// Success
|
|
235
|
+
{"success": true, "data": {"url": "https://example.com", "title": "Example"}}
|
|
236
|
+
|
|
237
|
+
// Error
|
|
238
|
+
{"success": false, "error": "Element not found: .missing"}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Non-zero exit code on errors — works naturally with `set -e` and `&&` chains.
|
|
242
|
+
|
|
243
|
+
## Architecture
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
┌─────────────────────────────────────────────────┐
|
|
247
|
+
│ Terminal │
|
|
248
|
+
│ $ bctl click "button.submit" │
|
|
249
|
+
│ │ │
|
|
250
|
+
│ ▼ HTTP POST localhost:19876/command │
|
|
251
|
+
│ ┌─────────────────────┐ │
|
|
252
|
+
│ │ Bridge Server │ (Python, aiohttp) │
|
|
253
|
+
│ │ :19876 │ │
|
|
254
|
+
│ └────────┬────────────┘ │
|
|
255
|
+
│ │ WebSocket │
|
|
256
|
+
│ ▼ │
|
|
257
|
+
│ ┌─────────────────────┐ │
|
|
258
|
+
│ │ Chrome Extension │ (Manifest V3) │
|
|
259
|
+
│ │ Service Worker │ │
|
|
260
|
+
│ └────────┬────────────┘ │
|
|
261
|
+
│ │ chrome.scripting / chrome.debugger │
|
|
262
|
+
│ ▼ │
|
|
263
|
+
│ ┌─────────────────────┐ │
|
|
264
|
+
│ │ Web Page │ │
|
|
265
|
+
│ └─────────────────────┘ │
|
|
266
|
+
└─────────────────────────────────────────────────┘
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- **CLI** → stdlib only, communicates via HTTP
|
|
270
|
+
- **Bridge Server** → async relay (aiohttp), auto-daemonizes
|
|
271
|
+
- **Extension** → MV3 service worker, auto-reconnects via `chrome.alarms`
|
|
272
|
+
- **Eval** → dual strategy: MAIN-world injection (fast) with CDP fallback (CSP-safe)
|
|
273
|
+
|
|
274
|
+
## Requirements
|
|
275
|
+
|
|
276
|
+
- Python >= 3.11
|
|
277
|
+
- Chrome / Chromium with the extension loaded
|
|
278
|
+
- macOS, Linux, or Windows
|
|
279
|
+
|
|
280
|
+
## Privacy
|
|
281
|
+
|
|
282
|
+
All communication is local (`127.0.0.1`). No analytics, no telemetry, no external servers. See [PRIVACY.md](PRIVACY.md) for the full privacy policy.
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
browser_ctl/SKILL.md,sha256=4HdyBgMWDolCFm18E3RSMzPHK21X3kc7fhdDpmFwO_U,5711
|
|
2
|
+
browser_ctl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
browser_ctl/__main__.py,sha256=J9HCyuMk9MmFWS5hp7v5DR0Y83hw3_ud6ZkF3F8361s,101
|
|
4
|
+
browser_ctl/cli.py,sha256=H7k_uovteB_-iWJ2u8ij7XOGZthfNWoYIu93I-HKOvY,16189
|
|
5
|
+
browser_ctl/server.py,sha256=uEZIC-VV10YikF0-SV46d27qsQXVBqkCkOhM9Fk0V8A,7463
|
|
6
|
+
browser_ctl-0.1.0.dist-info/licenses/LICENSE,sha256=MW4OAPawybk4g1YbwDgivl5BVBJRZe3sPC0qtxgn5Xc,1060
|
|
7
|
+
browser_ctl-0.1.0.dist-info/METADATA,sha256=EpGU4XeSlJ23SndHcx-eCfDlA6Rc2m-4iCmUMgGYljg,9120
|
|
8
|
+
browser_ctl-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
|
|
9
|
+
browser_ctl-0.1.0.dist-info/entry_points.txt,sha256=AwixT7GGghhdwc0qPawXHr-OiAP1cvbV-8m0WUEv70Q,84
|
|
10
|
+
browser_ctl-0.1.0.dist-info/top_level.txt,sha256=cvJDSm1xtuiPz2R8-M1cHsc_E7U0jYjYXgn07pG6xsY,12
|
|
11
|
+
browser_ctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browser_ctl
|