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 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
+ ```
File without changes
@@ -0,0 +1,4 @@
1
+ """Allow running the package with: python -m browser_ctl"""
2
+ from browser_ctl.cli import main
3
+
4
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (81.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bctl = browser_ctl.cli:main
3
+ bctl-server = browser_ctl.server:main
@@ -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