This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

10 kB 332 lines
1"""Command-line interface for browser-cli. 2 3Provides a minimal interface similar to pexpect-cli: 4- Execute JavaScript directly via stdin 5- List managed tabs 6- install-host for setup 7""" 8 9import argparse 10import asyncio 11import json 12import logging 13import shutil 14import sys 15from pathlib import Path 16from typing import Any 17 18from browser_cli.client import BrowserClient 19from browser_cli.errors import BrowserCLIError 20 21 22def install_native_host() -> None: 23 """Install native messaging host for Firefox.""" 24 server_path = shutil.which("browser-cli-server") 25 if not server_path: 26 print("Error: browser-cli-server not found in PATH", file=sys.stderr) 27 sys.exit(1) 28 29 home = Path.home() 30 31 # Create a wrapper script that doesn't hardcode nix store paths 32 wrapper_dir = home / ".local" / "bin" 33 wrapper_dir.mkdir(parents=True, exist_ok=True) 34 wrapper_path = wrapper_dir / "browser-cli-server-wrapper" 35 36 wrapper_content = f"""#!/usr/bin/env bash 37exec "{server_path}" "$@" 38""" 39 wrapper_path.write_text(wrapper_content) 40 wrapper_path.chmod(0o755) 41 42 host_dirs = [] 43 if sys.platform == "darwin": 44 app_support = home / "Library" / "Application Support" 45 host_dirs.append(app_support / "Mozilla" / "NativeMessagingHosts") 46 host_dirs.append(app_support / "LibreWolf" / "NativeMessagingHosts") 47 else: 48 host_dirs.append(home / ".mozilla" / "native-messaging-hosts") 49 50 for host_dir in host_dirs: 51 host_dir.mkdir(parents=True, exist_ok=True) 52 host_file = host_dir / "io.thalheim.browser_cli.bridge.json" 53 54 manifest = { 55 "name": "io.thalheim.browser_cli.bridge", 56 "description": "Browser CLI Bridge Server", 57 "path": str(wrapper_path), 58 "type": "stdio", 59 "allowed_extensions": ["browser-cli-controller@thalheim.io"], 60 } 61 62 with host_file.open("w") as f: 63 json.dump(manifest, f, indent=2) 64 65 print(f"Native messaging host installed successfully at {host_file}") 66 print(f"Using wrapper script at: {wrapper_path}") 67 68 69def _format_element(el: dict[str, Any]) -> str: 70 """Format a single element for display.""" 71 line = f"[{el['ref']}] {el['role']}" 72 if el.get("name"): 73 line += f' "{el["name"]}"' 74 if el.get("attrs"): 75 line += f" [{', '.join(el['attrs'])}]" 76 if el.get("value") is not None: 77 line += f' value="{el["value"]}"' 78 return line 79 80 81def _format_diff_section( 82 lines: list[str], 83 label: str, 84 items: list[dict[str, Any]], 85 prefix: str, 86) -> None: 87 """Format a diff section (added/removed).""" 88 if not items: 89 return 90 lines.extend(["", f"{label} ({len(items)}):"]) 91 lines.extend(f" {prefix} {_format_element(el)}" for el in items) 92 93 94def _format_diff(result: dict[str, Any]) -> str | None: 95 """Format a SnapshotDiff for display. Returns None if not a diff.""" 96 if "added" not in result or "removed" not in result or "changed" not in result: 97 return None 98 99 added, removed, changed = result["added"], result["removed"], result["changed"] 100 url_changed, title_changed = result.get("urlChanged"), result.get("titleChanged") 101 102 if not (url_changed or title_changed or added or removed or changed): 103 return "No changes" 104 105 lines: list[str] = [] 106 107 if url_changed: 108 lines.append(f"URL: {result['oldUrl']} → {result['newUrl']}") 109 if title_changed: 110 lines.append(f'Title: "{result["oldTitle"]}" → "{result["newTitle"]}"') 111 112 _format_diff_section(lines, "Added", added, "+") 113 _format_diff_section(lines, "Removed", removed, "-") 114 115 if changed: 116 lines.extend(["", f"Changed ({len(changed)}):"]) 117 for item in changed: 118 lines.append(f" ~ {_format_element(item['element'])}") 119 lines.extend(f" {c}" for c in item["changes"]) 120 121 return "\n".join(lines) 122 123 124def _format_snapshot_dict(result: dict[str, Any]) -> str | None: 125 """Format a snapshot dict for display. Returns None if not a snapshot.""" 126 # Check if it's a SnapshotDiff object 127 diff_result = _format_diff(result) 128 if diff_result is not None: 129 return diff_result 130 131 # Check if it's a Snapshot object 132 if "url" in result and "title" in result and "elements" in result: 133 lines = [f"Page: {result['title']}", f"URL: {result['url']}", ""] 134 lines.extend(_format_element(el) for el in result["elements"]) 135 return "\n".join(lines) 136 137 # Check if it's a single element 138 if "ref" in result and "role" in result: 139 return _format_element(result) 140 141 return None 142 143 144def _format_element_list(result: list[Any]) -> str | None: 145 """Format a list of elements for display. Returns None if not element list.""" 146 if not result or not isinstance(result[0], dict): 147 return None 148 149 if "ref" not in result[0] or "role" not in result[0]: 150 return None 151 152 return "\n".join(_format_element(el) for el in result) 153 154 155def format_snapshot( 156 result: dict[str, object] | list[object] | str | float | None, 157) -> str: 158 """Format a snapshot result for display.""" 159 if result is None: 160 return "" 161 162 if isinstance(result, str): 163 return result 164 165 if isinstance(result, dict): 166 formatted = _format_snapshot_dict(result) 167 if formatted is not None: 168 return formatted 169 170 # For arrays of elements 171 if isinstance(result, list): 172 formatted = _format_element_list(result) 173 if formatted is not None: 174 return formatted 175 176 # Default: JSON format 177 return json.dumps(result, indent=2) 178 179 180async def exec_js(tab_id: str | None, code: str, socket: str | None) -> None: 181 """Execute JavaScript code in a browser tab.""" 182 client = BrowserClient(socket) 183 result = await client.exec_js(code, tab_id) 184 if result is not None: 185 print(format_snapshot(result)) 186 187 188async def list_tabs(socket: str | None) -> None: 189 """List all managed tabs.""" 190 client = BrowserClient(socket) 191 tabs = await client.list_tabs() 192 if not tabs: 193 print("No managed tabs") 194 return 195 for tab in tabs: 196 tab_id = tab.get("id", "unknown") 197 url = tab.get("url", "about:blank") 198 title = tab.get("title", "Untitled") 199 active = " (active)" if tab.get("active") else "" 200 print(f"{tab_id}: {title}{active}") 201 print(f" {url}") 202 203 204def create_parser() -> argparse.ArgumentParser: 205 """Create and configure the argument parser.""" 206 parser = argparse.ArgumentParser( 207 description="Control Firefox browser from the command line", 208 formatter_class=argparse.RawDescriptionHelpFormatter, 209 epilog=""" 210Examples: 211 # List managed tabs 212 browser-cli --list 213 214 # Open page and get snapshot 215 browser-cli <<'EOF' 216 await tab("https://example.com") 217 snap() 218 EOF 219 220 # Form filling with refs 221 browser-cli <<'EOF' 222 await type(1, "user@test.com") 223 await type(2, "secret123") 224 await click(3) 225 EOF 226 227 # Wait for dynamic content 228 browser-cli <<'EOF' 229 await click(5) 230 await wait("text", "Success") 231 snap() 232 EOF 233 234Available JS API: 235 Interaction (use refs from snap()): 236 click(ref) - Click element 237 click(ref, {double}) - Double click 238 type(ref, text) - Type into input 239 type(ref, text, {clear}) - Clear first 240 hover(ref) - Hover element 241 drag(from, to) - Drag and drop 242 select(ref, value) - Select option 243 key(name) - Press key 244 245 Inspection: 246 snap() - Get page snapshot (diff after first call) 247 snap({full: true}) - Force full snapshot 248 snap({forms: true}) - Filter: form elements only 249 snap({links: true}) - Filter: links only 250 snap({text: "..."}) - Filter: by text 251 logs() - Get console logs 252 253 Waiting: 254 wait(ms) - Wait milliseconds 255 wait("idle") - Wait for DOM to stabilize 256 wait("text", str) - Wait for text to appear 257 wait("gone", str) - Wait for text to disappear 258 259 Media: 260 shot() - Screenshot (returns data URL) 261 shot(path) - Screenshot to file 262 download(url) - Download file to ~/Downloads 263 download(url, name) - Download with custom filename 264 265 Tabs: 266 tab() - New tab 267 tab(url) - New tab with URL 268 tabs() - List tabs 269 """, 270 ) 271 272 parser.add_argument( 273 "tab_id", 274 nargs="?", 275 help="Tab ID to execute code in. Code is read from stdin.", 276 ) 277 parser.add_argument( 278 "--list", 279 action="store_true", 280 help="List all managed tabs", 281 ) 282 parser.add_argument( 283 "--install-host", 284 action="store_true", 285 help="Install native messaging host for Firefox", 286 ) 287 parser.add_argument( 288 "--socket", 289 help="Unix socket path (default: $XDG_RUNTIME_DIR/browser-cli.sock)", 290 ) 291 parser.add_argument( 292 "--debug", 293 action="store_true", 294 help="Enable debug logging", 295 ) 296 297 return parser 298 299 300def main() -> None: 301 """Run the browser CLI.""" 302 parser = create_parser() 303 args = parser.parse_args() 304 305 if args.debug: 306 logging.basicConfig(level=logging.DEBUG) 307 308 try: 309 if args.install_host: 310 install_native_host() 311 elif args.list: 312 asyncio.run(list_tabs(args.socket)) 313 elif args.tab_id or not sys.stdin.isatty(): 314 code = sys.stdin.read() 315 if not code.strip(): 316 print("Error: No JavaScript code provided on stdin", file=sys.stderr) 317 sys.exit(1) 318 asyncio.run(exec_js(args.tab_id, code, args.socket)) 319 else: 320 parser.print_help() 321 sys.exit(1) 322 323 except BrowserCLIError as e: 324 print(f"Error: {e}", file=sys.stderr) 325 sys.exit(1) 326 except KeyboardInterrupt: 327 print("\nInterrupted by user", file=sys.stderr) 328 sys.exit(1) 329 330 331if __name__ == "__main__": 332 main()