This repository has no description
0

Configure Feed

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

skills.browser-cli: export via legacyPackages

+4099
+1
flake.nix
··· 171 171 ./5pkgs/flake-module.nix 172 172 ./keys/flake-module.nix 173 173 ./wrapperModules/flake-module.nix 174 + ./skills/flake-module.nix 174 175 ] 175 176 ++ ( 176 177 # Auto-import all flake-module.nix files from tools subdirectories
+29
skills/flake-module.nix
··· 1 + { 2 + inputs, 3 + lib, 4 + self, 5 + ... 6 + }: 7 + { 8 + perSystem = 9 + { pkgs, ... }: 10 + let 11 + skillDirs = builtins.attrNames ( 12 + lib.filterAttrs (_: type: type == "directory") (builtins.readDir ./.) 13 + ); 14 + 15 + buildSkill = name: 16 + let 17 + result = builtins.tryEval (pkgs.python3Packages.callPackage ./${name}/default.nix { }); 18 + in 19 + if result.success then result.value else null; 20 + 21 + skills = lib.filterAttrs (_: v: v != null) ( 22 + lib.genAttrs skillDirs buildSkill 23 + ); 24 + in 25 + { 26 + # Expose via legacyPackages so `nix run .#skills.browser-cli` works 27 + legacyPackages.skills = skills; 28 + }; 29 + }
+28
skills/install.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 + CLAUDE_SKILLS_DIR="${HOME}/.claude/skills" 6 + 7 + install_skill() { 8 + local skill_name="$1" 9 + local skill_dir="${SCRIPT_DIR}/${skill_name}" 10 + 11 + if [[ ! -f "${skill_dir}/SKILL.md" ]]; then 12 + echo "Error: ${skill_name}/SKILL.md not found" >&2 13 + return 1 14 + fi 15 + 16 + mkdir -p "${CLAUDE_SKILLS_DIR}/${skill_name}" 17 + ln -sf "${skill_dir}/SKILL.md" "${CLAUDE_SKILLS_DIR}/${skill_name}/SKILL.md" 18 + echo "Installed ${skill_name}" 19 + } 20 + 21 + if [[ $# -gt 0 ]]; then 22 + install_skill "$1" 23 + else 24 + for skill_dir in "${SCRIPT_DIR}"/*/; do 25 + skill_name="$(basename "${skill_dir}")" 26 + [[ -f "${skill_dir}/SKILL.md" ]] && install_skill "${skill_name}" 27 + done 28 + fi
+98
skills/browser-cli/README.md
··· 1 + # Browser CLI 2 + 3 + A command-line interface for controlling Firefox through WebExtensions API. 4 + Optimized for LLM agents with limited context windows. 5 + 6 + ## Overview 7 + 8 + Browser CLI consists of three components: 9 + 10 + 1. **Firefox Extension** - Executes commands in the browser and provides visual 11 + feedback 12 + 2. **Native Messaging Bridge** - Facilitates communication between the CLI and 13 + extension 14 + 3. **CLI Client** - Minimal command-line tool that executes JavaScript via stdin 15 + 16 + ## Installation 17 + 18 + ### For Nix Users 19 + 20 + ```bash 21 + nix run github:lassulus/superconfig#skills.browser-cli -- --help 22 + ``` 23 + 24 + ### Manual Installation 25 + 26 + 1. **Install the Firefox Extension** 27 + - Open Firefox 28 + - Navigate to `about:debugging` 29 + - Click "This Firefox" 30 + - Click "Load Temporary Add-on" 31 + - Select `manifest.json` from the `extension` directory 32 + 33 + 2. **Install Native Messaging Host** 34 + ```bash 35 + browser-cli --install-host 36 + ``` 37 + 38 + ## Usage 39 + 40 + See [SKILL.md](SKILL.md) for usage examples and JavaScript API reference. 41 + 42 + ## Architecture 43 + 44 + ``` 45 + ┌─────────────┐ Unix Socket ┌──────────────┐ Native ┌────────────┐ 46 + │ CLI │ ◄─────────────────► │ Bridge │ ◄─────────────► │ Extension │ 47 + │ (stdin) │ │ Server │ Messaging │ │ 48 + └─────────────┘ └──────────────┘ └────────────┘ 49 + ``` 50 + 51 + ## Development 52 + 53 + ### Project Structure 54 + 55 + ``` 56 + browser-cli/ 57 + ├── extension/ # Firefox WebExtension 58 + │ ├── manifest.json 59 + │ ├── background.js # Extension service worker 60 + │ └── content.js # Page automation and JS API 61 + ├── browser_cli/ # Python CLI package 62 + │ ├── cli.py # CLI entry point 63 + │ ├── client.py # Unix socket client 64 + │ ├── bridge.py # Native messaging bridge 65 + │ └── server.py # Bridge server 66 + └── pyproject.toml 67 + ``` 68 + 69 + ### Building 70 + 71 + For Nix users: 72 + 73 + ```bash 74 + nix build .#browser-cli 75 + ``` 76 + 77 + ## Troubleshooting 78 + 79 + ### Extension Not Connecting 80 + 81 + 1. Ensure Firefox is running 82 + 2. The Browser CLI extension is installed 83 + 3. Native messaging host is installed: `browser-cli --install-host` 84 + 4. Check Firefox console for errors: `Ctrl+Shift+J` 85 + 86 + ### Commands Timing Out 87 + 88 + - Use `wait()` for dynamic content: `await wait("text", "Loaded")` 89 + - Check element refs are current: `snap()` to refresh 90 + 91 + ### Stale Refs 92 + 93 + Refs are reset on each snapshot. If you get "Element [N] not found", call 94 + `snap()` to get fresh refs. 95 + 96 + ## License 97 + 98 + MIT
+153
skills/browser-cli/SKILL.md
··· 1 + --- 2 + name: browser-cli 3 + description: Control Firefox browser from the command line. Use for web automation, scraping, testing, or any browser interaction tasks. 4 + --- 5 + 6 + # Usage 7 + 8 + ```bash 9 + # List managed tabs 10 + browser-cli --list 11 + 12 + # Open a page and get snapshot 13 + browser-cli <<'EOF' 14 + await tab("https://example.com") 15 + snap() 16 + EOF 17 + 18 + # Execute in a specific tab 19 + browser-cli abc123 <<'EOF' 20 + snap() 21 + EOF 22 + ``` 23 + 24 + # JavaScript API 25 + 26 + All functions are available in the execution context. Actions return simple 27 + confirmations; use `snap()` to get page state. 28 + 29 + ## Element Interaction (use refs from snap()) 30 + 31 + ```bash 32 + browser-cli <<'EOF' 33 + await click(1) // Click element [1] 34 + await click(1, {double: true}) // Double click 35 + await type(2, "user@example.com") // Type into element [2] 36 + await type(2, "new", {clear: true}) // Clear first, then type 37 + await hover(3) // Hover over element [3] 38 + await drag(4, 5) // Drag from [4] to [5] 39 + await select(6, "option-value") // Select dropdown option 40 + key("Enter") // Press key 41 + key("Tab") 42 + EOF 43 + 44 + # Can still use CSS selectors when needed 45 + browser-cli <<'EOF' 46 + await click("#submit-button") 47 + await click("Sign In", "text") 48 + EOF 49 + ``` 50 + 51 + ## Page Inspection 52 + 53 + ```bash 54 + browser-cli <<'EOF' 55 + snap() // Get page snapshot with refs 56 + snap({forms: true}) // Only form elements 57 + snap({links: true}) // Only links 58 + snap({buttons: true}) // Only buttons 59 + snap({text: "login"}) // Elements containing "login" 60 + diff() // Show changes since last snap 61 + logs() // Get console logs 62 + EOF 63 + ``` 64 + 65 + ## Waiting 66 + 67 + ```bash 68 + browser-cli <<'EOF' 69 + await wait(1000) // Wait 1 second 70 + await wait("idle") // Wait for DOM to stabilize 71 + await wait("text", "Success") // Wait for text to appear 72 + await wait("gone", "Loading") // Wait for text to disappear 73 + EOF 74 + ``` 75 + 76 + ## Downloads 77 + 78 + ```bash 79 + browser-cli TABID <<'EOF' 80 + const url = document.querySelector('a[href*="pdf"]').href 81 + await download(url, "invoice.pdf") // Downloads to ~/Downloads/invoice.pdf 82 + EOF 83 + ``` 84 + 85 + ## Screenshots & Tabs 86 + 87 + ```bash 88 + browser-cli <<'EOF' 89 + await shot() // Screenshot, returns data URL 90 + await shot("/tmp/page.png") // Screenshot to file 91 + await tab() // New tab 92 + await tab("https://example.com") // New tab with URL 93 + await tabs() // List all tabs 94 + EOF 95 + ``` 96 + 97 + # Snapshot Output Format 98 + 99 + ``` 100 + Page: Example Site 101 + URL: https://example.com 102 + 103 + [1] heading "Welcome" 104 + [2] input[email] "Email" [required] 105 + [3] input[password] "Password" [required] 106 + [4] checkbox "Remember me" 107 + [5] button "Sign In" 108 + [6] link "Forgot password?" 109 + ``` 110 + 111 + - `[N]` - Reference number for use with click(), type(), etc. 112 + - Role and accessible name shown 113 + - Attributes in brackets: `[disabled]`, `[checked]`, `[required]`, etc. 114 + 115 + # Examples 116 + 117 + ```bash 118 + # Search on Google 119 + browser-cli <<'EOF' 120 + await tab("https://google.com") 121 + snap() 122 + EOF 123 + # Output shows [12] combobox "Suche" 124 + 125 + browser-cli <<'EOF' 126 + await type(12, "hello world") 127 + diff() 128 + EOF 129 + # Shows: Added (autocomplete options), Changed (input value) 130 + 131 + # Form filling 132 + browser-cli <<'EOF' 133 + await tab("https://example.com/login") 134 + snap() 135 + EOF 136 + # Output: [1] input "Email", [2] input "Password", [3] button "Sign In" 137 + 138 + browser-cli <<'EOF' 139 + await type(1, "user@test.com") 140 + await type(2, "secret123") 141 + await click(3) 142 + diff() 143 + EOF 144 + 145 + # Wait for dynamic content 146 + browser-cli <<'EOF' 147 + await click(5) 148 + await wait("text", "Success") 149 + snap() 150 + EOF 151 + ``` 152 + 153 + See [README.md](README.md) for installation, architecture, and full API reference.
+20
skills/browser-cli/default.nix
··· 1 + { 2 + buildPythonApplication, 3 + hatchling, 4 + }: 5 + 6 + buildPythonApplication { 7 + pname = "browser-cli"; 8 + version = "0.2.0"; 9 + src = ./.; 10 + pyproject = true; 11 + 12 + build-system = [ hatchling ]; 13 + 14 + dependencies = [ ]; 15 + 16 + meta = { 17 + description = "Control Firefox browser from the command line"; 18 + mainProgram = "browser-cli"; 19 + }; 20 + }
+45
skills/browser-cli/pyproject.toml
··· 1 + [build-system] 2 + requires = ["hatchling"] 3 + build-backend = "hatchling.build" 4 + 5 + [project] 6 + name = "browser-cli" 7 + version = "0.2.0" 8 + description = "Control Firefox browser from the command line" 9 + requires-python = ">=3.13" 10 + license = {text = "MIT"} 11 + authors = [ 12 + {name = "Jörg Thalheim", email = "joerg@thalheim.io"}, 13 + ] 14 + dependencies = [] 15 + 16 + [project.scripts] 17 + browser-cli = "browser_cli:main" 18 + browser-cli-server = "browser_cli.server:main" 19 + 20 + [project.optional-dependencies] 21 + dev = [ 22 + "ruff>=0.5.0", 23 + "mypy>=1.10.0", 24 + "pytest>=8.0.0", 25 + ] 26 + 27 + [tool.ruff] 28 + target-version = "py313" 29 + line-length = 100 30 + 31 + [tool.ruff.lint] 32 + select = ["ALL"] 33 + ignore = ["T201"] # Allow print() in CLI tools 34 + 35 + [tool.ruff.format] 36 + quote-style = "double" 37 + indent-style = "space" 38 + skip-magic-trailing-comma = false 39 + line-ending = "auto" 40 + 41 + [tool.mypy] 42 + python_version = "3.13" 43 + strict = true 44 + warn_return_any = true 45 + warn_unused_configs = true
+11
skills/browser-cli/browser_cli/__init__.py
··· 1 + """Browser CLI - Control Firefox from the command line. 2 + 3 + Provides a minimal CLI that executes JavaScript via stdin, with a rich 4 + JS API for browser automation. 5 + """ 6 + 7 + from browser_cli.bridge import NativeMessagingBridge 8 + from browser_cli.cli import main 9 + from browser_cli.client import BrowserClient 10 + 11 + __all__ = ["BrowserClient", "NativeMessagingBridge", "main"]
+394
skills/browser-cli/browser_cli/bridge.py
··· 1 + """Native messaging bridge that connects browser extension and CLI.""" 2 + 3 + import asyncio 4 + import base64 5 + import json 6 + import logging 7 + import struct 8 + import sys 9 + from pathlib import Path 10 + from typing import Any 11 + 12 + logger = logging.getLogger(__name__) 13 + 14 + 15 + class NativeMessagingBridge: 16 + """Bridge between browser extension (native messaging) and CLI (Unix socket).""" 17 + 18 + def __init__(self) -> None: 19 + """Initialize the bridge.""" 20 + self.cli_clients: set[tuple[asyncio.StreamReader, asyncio.StreamWriter]] = set() 21 + self.pending_responses: dict[str, asyncio.Future[Any]] = {} 22 + self.message_counter = 0 23 + self.stdin_reader: asyncio.StreamReader | None = None 24 + self.stdout_writer: asyncio.StreamWriter | None = None 25 + self.latest_tab_id: str | None = None # Track the most recently created tab 26 + 27 + async def setup_native_messaging(self) -> None: 28 + """Set up stdin/stdout for native messaging.""" 29 + loop = asyncio.get_event_loop() 30 + 31 + # Create stream reader for stdin 32 + self.stdin_reader = asyncio.StreamReader() 33 + stdin_protocol = asyncio.StreamReaderProtocol(self.stdin_reader) 34 + await loop.connect_read_pipe(lambda: stdin_protocol, sys.stdin) 35 + 36 + # Create stream writer for stdout 37 + stdout_transport, stdout_protocol = await loop.connect_write_pipe( 38 + asyncio.streams.FlowControlMixin, 39 + sys.stdout, 40 + ) 41 + self.stdout_writer = asyncio.StreamWriter(stdout_transport, stdout_protocol, None, loop) 42 + 43 + async def read_native_message(self) -> dict[str, Any] | None: 44 + """Read a message from native messaging stdin.""" 45 + if not self.stdin_reader: 46 + return None 47 + 48 + try: 49 + # Read message length (4 bytes, little-endian) 50 + length_bytes = await self.stdin_reader.readexactly(4) 51 + length = struct.unpack("<I", length_bytes)[0] 52 + 53 + # Read message body 54 + message_bytes = await self.stdin_reader.readexactly(length) 55 + message_data: dict[str, Any] = json.loads(message_bytes.decode("utf-8")) 56 + except asyncio.IncompleteReadError: 57 + logger.info("Native messaging input closed") 58 + return None 59 + except Exception: 60 + logger.exception("Error reading native message") 61 + return None 62 + else: 63 + return message_data 64 + 65 + async def write_native_message(self, message: dict[str, Any]) -> None: 66 + """Write a message to native messaging stdout.""" 67 + if not self.stdout_writer: 68 + return 69 + 70 + try: 71 + # Encode message 72 + message_bytes = json.dumps(message).encode("utf-8") 73 + 74 + # Write message length (4 bytes, little-endian) 75 + self.stdout_writer.write(struct.pack("<I", len(message_bytes))) 76 + 77 + # Write message body 78 + self.stdout_writer.write(message_bytes) 79 + 80 + await self.stdout_writer.drain() 81 + except Exception: 82 + logger.exception("Error writing native message") 83 + 84 + async def process_screenshot_response( 85 + self, 86 + response: dict[str, Any], 87 + output_path: str | None = None, 88 + ) -> dict[str, Any]: 89 + """Process screenshot response - save to file and return path.""" 90 + try: 91 + # Get data URL from response 92 + data_url = response["result"]["screenshot"] 93 + if not data_url.startswith("data:image/png;base64,"): 94 + return response # Return unchanged if not base64 PNG 95 + 96 + # Extract and decode base64 data 97 + base64_data = data_url.split(",")[1] 98 + image_data = base64.b64decode(base64_data) 99 + 100 + # Use provided output path or generate default 101 + if output_path: 102 + screenshot_path = Path(output_path) 103 + # Create parent directory if it doesn't exist 104 + screenshot_path.parent.mkdir(parents=True, exist_ok=True) 105 + else: 106 + # Fallback to current directory 107 + screenshot_path = Path.cwd() / "screenshot.png" 108 + 109 + # Save screenshot 110 + screenshot_path.write_bytes(image_data) 111 + logger.info("Saved screenshot to: %s", screenshot_path) 112 + 113 + # Return modified response with file path instead of data 114 + return { 115 + "id": response.get("id"), 116 + "success": True, 117 + "result": { 118 + "screenshot_path": str(screenshot_path), 119 + "message": f"Screenshot saved to {screenshot_path}", 120 + }, 121 + } 122 + except Exception as e: 123 + logger.exception("Error processing screenshot") 124 + return { 125 + "id": response.get("id"), 126 + "success": False, 127 + "error": f"Failed to save screenshot: {e!s}", 128 + } 129 + 130 + async def handle_extension_message(self, message: dict[str, Any]) -> None: 131 + """Process messages from browser extension.""" 132 + msg_id = message.get("id") 133 + command = message.get("command") 134 + 135 + # Handle commands from extension (e.g., save-screenshot from content script) 136 + if command == "save-screenshot": 137 + await self._handle_save_screenshot(message) 138 + return 139 + 140 + if msg_id and msg_id in self.pending_responses: 141 + # This is a response to a CLI request 142 + future = self.pending_responses.pop(msg_id) 143 + 144 + # Track the latest tab ID if this was a new-tab command 145 + if message.get("success") and "tabId" in message.get("result", {}): 146 + self.latest_tab_id = message["result"]["tabId"] 147 + 148 + future.set_result(message) 149 + else: 150 + # This is a command from the extension - shouldn't happen in our architecture 151 + logger.warning("Unexpected command from extension: %s", message) 152 + 153 + async def _handle_save_screenshot(self, message: dict[str, Any]) -> None: 154 + """Handle save-screenshot command from extension.""" 155 + msg_id = message.get("id") 156 + params = message.get("params", {}) 157 + data_url = params.get("screenshot", "") 158 + output_path = params.get("output_path") 159 + 160 + if not data_url.startswith("data:image/png;base64,"): 161 + await self.write_native_message( 162 + { 163 + "id": msg_id, 164 + "success": False, 165 + "error": "Invalid screenshot data URL", 166 + }, 167 + ) 168 + return 169 + 170 + try: 171 + # Extract and decode base64 data 172 + base64_data = data_url.split(",")[1] 173 + image_data = base64.b64decode(base64_data) 174 + 175 + # Use provided output path or generate default 176 + if output_path: 177 + screenshot_path = Path(output_path) 178 + screenshot_path.parent.mkdir(parents=True, exist_ok=True) 179 + else: 180 + screenshot_path = Path.cwd() / "screenshot.png" 181 + 182 + # Save screenshot 183 + screenshot_path.write_bytes(image_data) 184 + logger.info("Saved screenshot to: %s", screenshot_path) 185 + 186 + # Send success response back to extension 187 + await self.write_native_message( 188 + { 189 + "id": msg_id, 190 + "success": True, 191 + "result": { 192 + "screenshot_path": str(screenshot_path), 193 + "message": f"Screenshot saved to {screenshot_path}", 194 + }, 195 + }, 196 + ) 197 + except Exception as e: 198 + logger.exception("Error saving screenshot") 199 + await self.write_native_message( 200 + { 201 + "id": msg_id, 202 + "success": False, 203 + "error": f"Failed to save screenshot: {e!s}", 204 + }, 205 + ) 206 + 207 + async def handle_cli_client( 208 + self, 209 + reader: asyncio.StreamReader, 210 + writer: asyncio.StreamWriter, 211 + ) -> None: 212 + """Handle connection from CLI client.""" 213 + logger.info("CLI client connected") 214 + client = (reader, writer) 215 + self.cli_clients.add(client) 216 + 217 + try: 218 + while True: 219 + # Read JSON line 220 + line = await reader.readline() 221 + if not line: 222 + break 223 + 224 + await self.handle_cli_message(writer, line.decode("utf-8").strip()) 225 + 226 + except Exception: 227 + logger.exception("Error handling CLI client") 228 + finally: 229 + logger.info("CLI client disconnected") 230 + self.cli_clients.discard(client) 231 + writer.close() 232 + await writer.wait_closed() 233 + 234 + async def _ensure_tab_exists( 235 + self, 236 + data: dict[str, Any], 237 + msg_id: str, 238 + writer: asyncio.StreamWriter, 239 + ) -> bool: 240 + """Ensure a tab exists for commands that need one. 241 + 242 + Returns True if tab exists or was created, False if failed. 243 + """ 244 + command = data.get("command", "") 245 + if command in ["list-tabs", "new-tab"] or "tabId" in data: 246 + return True 247 + 248 + if self.latest_tab_id: 249 + data["tabId"] = self.latest_tab_id 250 + return True 251 + 252 + # No tabs exist, create one 253 + logger.info("No managed tabs exist, creating a new tab") 254 + new_tab_id = f"auto_{msg_id}" 255 + new_tab_msg = { 256 + "command": "new-tab", 257 + "params": {"url": "https://example.com"}, 258 + "id": new_tab_id, 259 + } 260 + new_tab_future: asyncio.Future[Any] = asyncio.Future() 261 + self.pending_responses[new_tab_id] = new_tab_future 262 + await self.write_native_message(new_tab_msg) 263 + 264 + try: 265 + new_tab_response = await asyncio.wait_for(new_tab_future, timeout=5.0) 266 + result = new_tab_response.get("result", {}) 267 + if new_tab_response.get("success") and "tabId" in result: 268 + self.latest_tab_id = new_tab_response["result"]["tabId"] 269 + data["tabId"] = self.latest_tab_id 270 + logger.info("Created new tab: %s", self.latest_tab_id) 271 + return True 272 + logger.error("Failed to create new tab - no tabId in response") 273 + self.latest_tab_id = None 274 + except TimeoutError: 275 + logger.exception("Timeout creating new tab") 276 + await self._send_error_response( 277 + writer, 278 + msg_id, 279 + "No managed tabs exist. Use 'browser-cli new-tab'", 280 + ) 281 + return False 282 + else: 283 + return False 284 + 285 + async def _send_error_response( 286 + self, 287 + writer: asyncio.StreamWriter, 288 + msg_id: str, 289 + error: str, 290 + ) -> None: 291 + """Send error response to CLI client.""" 292 + error_response = {"error": error, "id": msg_id} 293 + writer.write((json.dumps(error_response) + "\n").encode("utf-8")) 294 + await writer.drain() 295 + 296 + async def _send_response(self, writer: asyncio.StreamWriter, response: dict[str, Any]) -> None: 297 + """Send response to CLI client.""" 298 + writer.write((json.dumps(response) + "\n").encode("utf-8")) 299 + await writer.drain() 300 + 301 + async def handle_cli_message( 302 + self, 303 + writer: asyncio.StreamWriter, 304 + message: str, 305 + ) -> None: 306 + """Process messages from CLI client.""" 307 + try: 308 + data = json.loads(message) 309 + 310 + # Use the client's ID or generate one if missing 311 + msg_id = data.get("id") 312 + if not msg_id: 313 + msg_id = f"cli_{self.message_counter}" 314 + self.message_counter += 1 315 + data["id"] = msg_id 316 + 317 + # Ensure tab exists for commands that need it 318 + if not await self._ensure_tab_exists(data, msg_id, writer): 319 + return 320 + 321 + # Create future for response 322 + future: asyncio.Future[Any] = asyncio.Future() 323 + self.pending_responses[msg_id] = future 324 + 325 + # Forward to extension via native messaging 326 + await self.write_native_message(data) 327 + 328 + # Wait for response with timeout 329 + try: 330 + response = await asyncio.wait_for(future, timeout=30.0) 331 + 332 + # Special handling for screenshot command 333 + command = data.get("command", "") 334 + if ( 335 + command == "screenshot" 336 + and response.get("success") 337 + and response.get("result", {}).get("screenshot") 338 + ): 339 + output_path = data.get("params", {}).get("output_path") 340 + response = await self.process_screenshot_response(response, output_path) 341 + 342 + await self._send_response(writer, response) 343 + except TimeoutError: 344 + self.pending_responses.pop(msg_id, None) 345 + await self._send_error_response(writer, msg_id, "Request timeout") 346 + 347 + except json.JSONDecodeError: 348 + logger.exception("Invalid JSON from CLI: %s", message) 349 + await self._send_response(writer, {"error": "Invalid JSON"}) 350 + 351 + async def native_messaging_loop(self) -> None: 352 + """Handle native messaging in a loop.""" 353 + await self.setup_native_messaging() 354 + 355 + while True: 356 + message = await self.read_native_message() 357 + if message is None: 358 + break 359 + 360 + await self.handle_extension_message(message) 361 + 362 + async def start(self, socket_path: Path) -> None: 363 + """Start the bridge server.""" 364 + # Remove old socket if exists 365 + socket_path.unlink(missing_ok=True) 366 + 367 + # Start Unix socket server for CLI 368 + server = await asyncio.start_unix_server( 369 + self.handle_cli_client, 370 + socket_path, 371 + ) 372 + 373 + # Set socket permissions 374 + socket_path.chmod(0o600) 375 + 376 + # Send socket path to extension via native messaging 377 + await self.write_native_message( 378 + { 379 + "socket_path": str(socket_path), 380 + "ready": True, 381 + }, 382 + ) 383 + 384 + logger.info("Native messaging bridge started") 385 + logger.info("CLI socket: %s", socket_path) 386 + 387 + # Run native messaging loop 388 + try: 389 + await self.native_messaging_loop() 390 + finally: 391 + # Clean shutdown 392 + server.close() 393 + await server.wait_closed() 394 + socket_path.unlink(missing_ok=True)
+332
skills/browser-cli/browser_cli/cli.py
··· 1 + """Command-line interface for browser-cli. 2 + 3 + Provides a minimal interface similar to pexpect-cli: 4 + - Execute JavaScript directly via stdin 5 + - List managed tabs 6 + - install-host for setup 7 + """ 8 + 9 + import argparse 10 + import asyncio 11 + import json 12 + import logging 13 + import shutil 14 + import sys 15 + from pathlib import Path 16 + from typing import Any 17 + 18 + from browser_cli.client import BrowserClient 19 + from browser_cli.errors import BrowserCLIError 20 + 21 + 22 + def 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 37 + exec "{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 + 69 + def _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 + 81 + def _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 + 94 + def _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 + 124 + def _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 + 144 + def _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 + 155 + def 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 + 180 + async 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 + 188 + async 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 + 204 + def 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=""" 210 + Examples: 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 + 234 + Available 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 + 300 + def 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 + 331 + if __name__ == "__main__": 332 + main()
+102
skills/browser-cli/browser_cli/client.py
··· 1 + """Browser CLI client - Unix socket client for browser automation.""" 2 + 3 + import asyncio 4 + import json 5 + import os 6 + import tempfile 7 + from pathlib import Path 8 + from typing import Any 9 + 10 + from browser_cli.errors import BrowserConnectionError, CommandError 11 + 12 + 13 + class BrowserClient: 14 + """Unix socket client for browser automation.""" 15 + 16 + def __init__(self, socket_path: str | None = None) -> None: 17 + """Initialize the browser CLI client.""" 18 + if socket_path: 19 + self.socket_path = socket_path 20 + else: 21 + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") 22 + if not runtime_dir: 23 + runtime_dir = tempfile.gettempdir() 24 + self.socket_path = str(Path(runtime_dir) / "browser-cli.sock") 25 + 26 + self.message_counter = 0 27 + 28 + async def send_command( 29 + self, 30 + command: str, 31 + params: dict[str, Any] | None = None, 32 + tab_id: str | None = None, 33 + ) -> dict[str, Any]: 34 + """Send command to browser and wait for response.""" 35 + self.message_counter += 1 36 + message_id = str(self.message_counter) 37 + 38 + message: dict[str, Any] = { 39 + "command": command, 40 + "params": params or {}, 41 + "id": message_id, 42 + } 43 + 44 + if tab_id: 45 + message["tabId"] = tab_id 46 + 47 + try: 48 + reader, writer = await asyncio.open_unix_connection( 49 + self.socket_path, 50 + limit=10 * 1024 * 1024, # 10MB for screenshots 51 + ) 52 + 53 + writer.write((json.dumps(message) + "\n").encode("utf-8")) 54 + await writer.drain() 55 + 56 + response_line = await reader.readline() 57 + if not response_line: 58 + msg = "No response from server" 59 + raise CommandError(msg) 60 + 61 + response = json.loads(response_line.decode("utf-8").strip()) 62 + 63 + writer.close() 64 + await writer.wait_closed() 65 + 66 + if response.get("id") == message_id: 67 + if response.get("success"): 68 + result: dict[str, Any] = response.get("result", {}) 69 + return result 70 + error_msg = response.get("error", "Unknown error") 71 + raise CommandError(error_msg) from None 72 + 73 + msg = f"Invalid response ID. Expected: {message_id}, Got: {response.get('id')}" 74 + raise CommandError(msg) 75 + 76 + except (ConnectionRefusedError, FileNotFoundError) as e: 77 + msg = ( 78 + "Cannot connect to browser extension. Make sure:\n" 79 + "1. Firefox is running\n" 80 + "2. The Browser CLI extension is installed\n" 81 + "3. The extension is enabled on the current tab\n" 82 + f"\nSocket path: {self.socket_path}" 83 + ) 84 + raise BrowserConnectionError(msg) from e 85 + 86 + async def exec_js( 87 + self, 88 + code: str, 89 + tab_id: str | None = None, 90 + ) -> dict[str, object] | list[object] | str | int | float | bool | None: 91 + """Execute JavaScript code in the browser and return result.""" 92 + result = await self.send_command("exec", {"code": code}, tab_id) 93 + js_result: dict[str, object] | list[object] | str | int | float | bool | None = result.get( 94 + "result", 95 + ) 96 + return js_result 97 + 98 + async def list_tabs(self) -> list[dict[str, Any]]: 99 + """List all managed tabs.""" 100 + result = await self.send_command("list-tabs") 101 + tabs: list[dict[str, Any]] = result.get("tabs", []) 102 + return tabs
+13
skills/browser-cli/browser_cli/errors.py
··· 1 + """Custom exceptions for browser-cli.""" 2 + 3 + 4 + class BrowserCLIError(Exception): 5 + """Base exception for browser-cli errors.""" 6 + 7 + 8 + class BrowserConnectionError(BrowserCLIError): 9 + """Raised when unable to connect to the browser extension or server.""" 10 + 11 + 12 + class CommandError(BrowserCLIError): 13 + """Raised when a command fails to execute."""
+96
skills/browser-cli/browser_cli/server.py
··· 1 + #!/usr/bin/env python3 2 + """Native messaging host that bridges browser extension and CLI. 3 + 4 + The browser extension communicates via native messaging (stdin/stdout). 5 + The CLI connects via Unix socket using JSON lines protocol. 6 + """ 7 + 8 + import asyncio 9 + import contextlib 10 + import logging 11 + import logging.handlers 12 + import os 13 + import signal 14 + import sys 15 + import tempfile 16 + from pathlib import Path 17 + 18 + from browser_cli.bridge import NativeMessagingBridge 19 + 20 + logger = logging.getLogger(__name__) 21 + 22 + 23 + def setup_logging() -> None: 24 + """Set up logging to both terminal and file.""" 25 + # Get XDG data home 26 + xdg_data_home = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share")) 27 + log_dir = Path(xdg_data_home) / "browser-cli" 28 + log_dir.mkdir(parents=True, exist_ok=True) 29 + log_file = log_dir / "server.log" 30 + 31 + # Set up root logger 32 + root_logger = logging.getLogger() 33 + root_logger.setLevel(logging.INFO) 34 + 35 + # File handler only - stdout is used for native messaging 36 + file_handler = logging.handlers.RotatingFileHandler( 37 + log_file, 38 + maxBytes=10 * 1024 * 1024, # 10MB 39 + backupCount=5, 40 + encoding="utf-8", 41 + ) 42 + file_handler.setLevel(logging.INFO) 43 + file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 44 + file_handler.setFormatter(file_formatter) 45 + 46 + # Add handler to root logger 47 + root_logger.addHandler(file_handler) 48 + 49 + logger.info("Logging initialized. Log file: %s", log_file) 50 + 51 + 52 + async def async_main() -> None: 53 + """Run the native messaging bridge.""" 54 + bridge = NativeMessagingBridge() 55 + 56 + # Handle shutdown gracefully 57 + stop: asyncio.Future[None] = asyncio.Future() 58 + 59 + def signal_handler() -> None: 60 + stop.set_result(None) 61 + 62 + loop = asyncio.get_event_loop() 63 + for sig in (signal.SIGTERM, signal.SIGINT): 64 + loop.add_signal_handler(sig, signal_handler) 65 + 66 + # Get runtime directory for Unix socket 67 + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") 68 + if not runtime_dir: 69 + runtime_dir = tempfile.gettempdir() 70 + 71 + socket_path = Path(runtime_dir) / "browser-cli.sock" 72 + 73 + # Run bridge until stopped 74 + bridge_task = asyncio.create_task(bridge.start(socket_path)) 75 + 76 + await asyncio.wait([bridge_task, stop], return_when=asyncio.FIRST_COMPLETED) 77 + 78 + # Cancel bridge task if still running 79 + if not bridge_task.done(): 80 + bridge_task.cancel() 81 + with contextlib.suppress(asyncio.CancelledError): 82 + await bridge_task 83 + 84 + 85 + def main() -> None: 86 + """Run the native messaging bridge server.""" 87 + setup_logging() 88 + try: 89 + asyncio.run(async_main()) 90 + except KeyboardInterrupt: 91 + logger.info("Server stopped by user") 92 + sys.exit(0) 93 + 94 + 95 + if __name__ == "__main__": 96 + main()
+743
skills/browser-cli/extension/background.js
··· 1 + /// <reference types="firefox-webext-browser" /> 2 + 3 + /** 4 + * Background script for Browser CLI Controller 5 + * Manages native messaging connection and message routing 6 + * 7 + * @typedef {object} NativeMessage 8 + * @property {boolean} [ready] - Whether the native messaging bridge is ready 9 + * @property {string} [socket_path] - Path to the Unix socket 10 + * @property {string} [command] - The command to execute 11 + * @property {CommandParams} [params] - Command parameters 12 + * @property {string} [id] - Unique message ID 13 + * @property {string} [tabId] - Target tab ID for the command 14 + * 15 + * @typedef {object} Message 16 + * @property {string} command - The command to execute 17 + * @property {CommandParams} params - Command parameters 18 + * @property {string} id - Unique message ID 19 + * @property {string} [tabId] - Target tab ID for the command 20 + * 21 + * @typedef {object} CommandParams 22 + * @property {string} [url] - URL for navigation or new tab 23 + * @property {string} [tabId] - Tab ID for close-tab 24 + * @property {string} [code] - JavaScript code to execute 25 + * @property {string} [output_path] - Output path for screenshot 26 + * @property {string} [filename] - Filename for download 27 + */ 28 + 29 + /** @type {browser.runtime.Port|undefined} Native messaging port */ 30 + let nativePort; 31 + 32 + /** @type {Record<string, {resolve: Function, reject: Function}>} Message handlers from content scripts */ 33 + const messageHandlers = {}; 34 + 35 + /** @type {Map<string, {tabId: number, url: string, title: string}>} Map of managed tabs with short IDs */ 36 + const managedTabs = new Map(); 37 + 38 + /** @type {string|undefined} Currently active managed tab ID */ 39 + let activeTabId; 40 + 41 + /** 42 + * Generate a short random ID for tabs 43 + * @returns {string} 44 + */ 45 + function generateTabId() { 46 + const chars = 47 + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 48 + let result = ""; 49 + for (let i = 0; i < 6; i++) { 50 + result += chars.charAt(Math.floor(Math.random() * chars.length)); 51 + } 52 + return result; 53 + } 54 + 55 + /** 56 + * Connect to native messaging host 57 + */ 58 + function connectNativeHost() { 59 + if (nativePort) return; 60 + 61 + console.log("Connecting to native messaging host..."); 62 + 63 + try { 64 + nativePort = browser.runtime.connectNative( 65 + "io.thalheim.browser_cli.bridge", 66 + ); 67 + 68 + nativePort.onMessage.addListener(async (msg) => { 69 + const message = /** @type {NativeMessage} */ (msg); 70 + console.log("Received from native host:", message); 71 + 72 + if (message.ready && message.socket_path) { 73 + console.log( 74 + "Native messaging bridge ready, socket at:", 75 + message.socket_path, 76 + ); 77 + return; 78 + } 79 + 80 + // Handle responses to our requests (e.g., save-screenshot) 81 + if (message.id && messageHandlers[message.id]) { 82 + const handler = messageHandlers[message.id]; 83 + delete messageHandlers[message.id]; 84 + handler.resolve(message); 85 + return; 86 + } 87 + 88 + if (message.command) { 89 + await handleCommand(/** @type {Message} */ (message)); 90 + } 91 + }); 92 + 93 + nativePort.onDisconnect.addListener(() => { 94 + console.log("Native messaging disconnected"); 95 + if (browser.runtime.lastError) { 96 + console.error("Native messaging error:", browser.runtime.lastError); 97 + } 98 + nativePort = undefined; 99 + setTimeout(connectNativeHost, 5000); 100 + }); 101 + } catch (error) { 102 + console.error("Failed to connect to native messaging host:", error); 103 + nativePort = undefined; 104 + } 105 + } 106 + 107 + /** 108 + * Send response back to native host 109 + * @param {object} response 110 + */ 111 + function sendResponse(response) { 112 + if (nativePort) { 113 + try { 114 + nativePort.postMessage(response); 115 + } catch (error) { 116 + console.error("Failed to send response:", error); 117 + } 118 + } 119 + } 120 + 121 + /** 122 + * Get the browser tab ID to use for a command 123 + * @param {string} [targetTabId] 124 + * @returns {Promise<number>} 125 + */ 126 + async function getTargetTab(targetTabId) { 127 + if (targetTabId) { 128 + const managedTab = managedTabs.get(targetTabId); 129 + if (!managedTab) { 130 + throw new Error(`Tab ${targetTabId} not found`); 131 + } 132 + return managedTab.tabId; 133 + } else if (activeTabId) { 134 + const managedTab = managedTabs.get(activeTabId); 135 + if (!managedTab) { 136 + throw new Error(`Active tab ${activeTabId} no longer exists`); 137 + } 138 + return managedTab.tabId; 139 + } else { 140 + throw new Error("No active tab. Create a tab first with newTab()"); 141 + } 142 + } 143 + 144 + /** 145 + * Send command to content script 146 + * @param {string} command 147 + * @param {object} [params={}] 148 + * @param {string} [targetTabId] 149 + * @returns {Promise<object>} 150 + */ 151 + async function sendToContentScript(command, params = {}, targetTabId) { 152 + const tabId = await getTargetTab(targetTabId); 153 + 154 + return new Promise((resolve, reject) => { 155 + const messageId = Date.now().toString(); 156 + messageHandlers[messageId] = { resolve, reject }; 157 + 158 + browser.tabs.sendMessage(tabId, { command, params, messageId }); 159 + 160 + setTimeout(() => { 161 + if (messageHandlers[messageId]) { 162 + delete messageHandlers[messageId]; 163 + reject(new Error("Content script timeout")); 164 + } 165 + }, 30_000); 166 + }); 167 + } 168 + 169 + // ============================================================================ 170 + // Browser-level commands (require background script) 171 + // ============================================================================ 172 + 173 + /** 174 + * Navigate to URL 175 + * @param {string} url 176 + * @param {string} [tabId] 177 + * @returns {Promise<{message: string}>} 178 + */ 179 + async function navigate(url, tabId) { 180 + const browserTabId = await getTargetTab(tabId); 181 + await browser.tabs.update(browserTabId, { url }); 182 + 183 + const managedTabId = tabId || activeTabId; 184 + if (managedTabId && managedTabs.has(managedTabId)) { 185 + const managedTab = managedTabs.get(managedTabId); 186 + if (managedTab) managedTab.url = url; 187 + } 188 + 189 + return { message: `Navigated to ${url}` }; 190 + } 191 + 192 + /** 193 + * Navigate back 194 + * @param {string} [tabId] 195 + * @returns {Promise<{message: string}>} 196 + */ 197 + async function goBack(tabId) { 198 + const browserTabId = await getTargetTab(tabId); 199 + await browser.tabs.goBack(browserTabId); 200 + return { message: "Navigated back" }; 201 + } 202 + 203 + /** 204 + * Navigate forward 205 + * @param {string} [tabId] 206 + * @returns {Promise<{message: string}>} 207 + */ 208 + async function goForward(tabId) { 209 + const browserTabId = await getTargetTab(tabId); 210 + await browser.tabs.goForward(browserTabId); 211 + return { message: "Navigated forward" }; 212 + } 213 + 214 + /** 215 + * Take a screenshot 216 + * @param {string} [tabId] 217 + * @returns {Promise<{screenshot: string}>} 218 + */ 219 + async function takeScreenshot(tabId) { 220 + const browserTabId = await getTargetTab(tabId); 221 + await browser.tabs.update(browserTabId, { active: true }); 222 + const dataUrl = await browser.tabs.captureVisibleTab(); 223 + return { screenshot: dataUrl }; 224 + } 225 + 226 + /** 227 + * Save screenshot by sending it through native messaging to the Python bridge 228 + * @param {string} dataUrl - The base64 data URL of the screenshot 229 + * @param {string} outputPath - The file path to save to 230 + * @returns {Promise<{screenshot_path: string, message: string}>} 231 + */ 232 + async function saveScreenshotToFile(dataUrl, outputPath) { 233 + if (!nativePort) { 234 + throw new Error( 235 + "Native messaging not connected - cannot save screenshot to file", 236 + ); 237 + } 238 + 239 + // Send screenshot save request through native messaging 240 + const messageId = `screenshot_save_${Date.now()}`; 241 + 242 + return new Promise((resolve, reject) => { 243 + const timeout = setTimeout(() => { 244 + delete messageHandlers[messageId]; 245 + reject(new Error("Screenshot save timeout")); 246 + }, 30_000); 247 + 248 + messageHandlers[messageId] = { 249 + resolve: (response) => { 250 + clearTimeout(timeout); 251 + if (response.success && response.result) { 252 + resolve(response.result); 253 + } else { 254 + reject(new Error(response.error || "Failed to save screenshot")); 255 + } 256 + }, 257 + reject: (error) => { 258 + clearTimeout(timeout); 259 + reject(error); 260 + }, 261 + }; 262 + 263 + nativePort.postMessage({ 264 + command: "save-screenshot", 265 + params: { 266 + screenshot: dataUrl, 267 + output_path: outputPath, 268 + }, 269 + id: messageId, 270 + }); 271 + }); 272 + } 273 + 274 + /** 275 + * List all managed tabs 276 + * @returns {Promise<{tabs: Array<{id: string, tabId: number, url: string, title: string, active: boolean}>}>} 277 + */ 278 + async function listTabs() { 279 + const tabs = []; 280 + const activeBrowserTab = await browser.tabs.query({ 281 + active: true, 282 + currentWindow: true, 283 + }); 284 + const activeBrowserTabId = activeBrowserTab[0]?.id; 285 + 286 + for (const [shortId, tab] of managedTabs) { 287 + try { 288 + const browserTab = await browser.tabs.get(tab.tabId); 289 + tabs.push({ 290 + id: shortId, 291 + tabId: tab.tabId, 292 + url: browserTab.url || "", 293 + title: browserTab.title || "Untitled", 294 + active: browserTab.id === activeBrowserTabId, 295 + }); 296 + } catch { 297 + managedTabs.delete(shortId); 298 + if (activeTabId === shortId) activeTabId = undefined; 299 + } 300 + } 301 + 302 + return { tabs }; 303 + } 304 + 305 + /** 306 + * Create a new managed tab 307 + * @param {string} [url] 308 + * @returns {Promise<{tabId: string, url: string}>} 309 + */ 310 + async function createNewTab(url) { 311 + const shortId = generateTabId(); 312 + const tabUrl = url || "about:blank"; 313 + 314 + const tab = await browser.tabs.create({ url: tabUrl, active: true }); 315 + 316 + if (tab.id === undefined) { 317 + throw new Error("Failed to create tab"); 318 + } 319 + 320 + managedTabs.set(shortId, { 321 + tabId: tab.id, 322 + url: tabUrl, 323 + title: tab.title || "New Tab", 324 + }); 325 + 326 + activeTabId = shortId; 327 + await enableOnTab(tab.id, shortId); 328 + 329 + return { tabId: shortId, url: tabUrl }; 330 + } 331 + 332 + /** 333 + * Close a managed tab 334 + * @param {string} [tabId] 335 + * @returns {Promise<{message: string}>} 336 + */ 337 + async function closeTab(tabId) { 338 + const targetId = tabId || activeTabId; 339 + if (!targetId) { 340 + throw new Error("No tab to close"); 341 + } 342 + 343 + const managedTab = managedTabs.get(targetId); 344 + if (!managedTab) { 345 + throw new Error(`Tab ${targetId} not found`); 346 + } 347 + 348 + await browser.tabs.remove(managedTab.tabId); 349 + managedTabs.delete(targetId); 350 + 351 + if (activeTabId === targetId) { 352 + activeTabId = managedTabs.keys().next().value; 353 + } 354 + 355 + return { message: `Closed tab ${targetId}` }; 356 + } 357 + 358 + /** 359 + * Download a file 360 + * @param {string} url - URL to download 361 + * @param {string} [filename] - Filename (relative to downloads folder) 362 + * @returns {Promise<{downloaded: string, path: string}>} 363 + */ 364 + async function downloadFile(url, filename) { 365 + /** @type {browser.downloads._DownloadOptions} */ 366 + const downloadOptions = { 367 + url, 368 + saveAs: false, 369 + filename: filename || undefined, 370 + }; 371 + 372 + const downloadId = await browser.downloads.download(downloadOptions); 373 + 374 + // Wait for download to complete 375 + return new Promise((resolve, reject) => { 376 + const timeout = setTimeout(() => { 377 + browser.downloads.onChanged.removeListener(listener); 378 + reject(new Error("Download timeout")); 379 + }, 60_000); 380 + 381 + /** 382 + * @param {browser.downloads._OnChangedDownloadDelta} delta 383 + */ 384 + function listener(delta) { 385 + if (delta.id !== downloadId) { 386 + return; 387 + } 388 + 389 + if (delta.state?.current === "complete") { 390 + clearTimeout(timeout); 391 + browser.downloads.onChanged.removeListener(listener); 392 + browser.downloads.search({ id: downloadId }).then((downloads) => { 393 + const path = downloads[0]?.filename || filename || "unknown"; 394 + resolve({ downloaded: url, path }); 395 + }); 396 + } else if (delta.error) { 397 + clearTimeout(timeout); 398 + browser.downloads.onChanged.removeListener(listener); 399 + reject(new Error(delta.error.current)); 400 + } 401 + } 402 + 403 + browser.downloads.onChanged.addListener(listener); 404 + }); 405 + } 406 + 407 + // ============================================================================ 408 + // Command handling 409 + // ============================================================================ 410 + 411 + /** 412 + * Handle commands from CLI 413 + * @param {Message} message 414 + */ 415 + async function handleCommand(message) { 416 + const { command, params, id, tabId } = message; 417 + 418 + try { 419 + let result; 420 + 421 + switch (command) { 422 + // Browser-level commands 423 + case "navigate": { 424 + result = await navigate(params.url || "", tabId); 425 + break; 426 + } 427 + case "back": { 428 + result = await goBack(tabId); 429 + break; 430 + } 431 + case "forward": { 432 + result = await goForward(tabId); 433 + break; 434 + } 435 + case "screenshot": { 436 + result = await takeScreenshot(tabId); 437 + break; 438 + } 439 + case "list-tabs": { 440 + result = await listTabs(); 441 + break; 442 + } 443 + case "new-tab": { 444 + result = await createNewTab(params.url); 445 + break; 446 + } 447 + case "close-tab": { 448 + result = await closeTab(params.tabId || tabId); 449 + break; 450 + } 451 + 452 + case "download": { 453 + result = await downloadFile(params.url || "", params.filename); 454 + break; 455 + } 456 + 457 + // Execute JavaScript in content script 458 + case "exec": { 459 + result = await sendToContentScript("exec", params, tabId); 460 + break; 461 + } 462 + 463 + default: { 464 + throw new Error(`Unknown command: ${command}`); 465 + } 466 + } 467 + 468 + sendResponse({ id, result, success: true }); 469 + } catch (error) { 470 + sendResponse({ 471 + id, 472 + error: error instanceof Error ? error.message : String(error), 473 + success: false, 474 + }); 475 + } 476 + } 477 + 478 + // ============================================================================ 479 + // Tab management 480 + // ============================================================================ 481 + 482 + /** 483 + * Enable extension on a specific tab 484 + * @param {number} tabId 485 + * @param {string} shortId 486 + */ 487 + async function enableOnTab(tabId, shortId) { 488 + await browser.tabs.executeScript(tabId, { file: "content.js" }); 489 + 490 + await browser.tabs.executeScript(tabId, { 491 + code: `(${function (/** @type {string} */ id) { 492 + if (!document.querySelector("#browser-cli-banner")) { 493 + const banner = document.createElement("div"); 494 + banner.id = "browser-cli-banner"; 495 + Object.assign(banner.style, { 496 + position: "fixed", 497 + top: "0", 498 + left: "0", 499 + right: "0", 500 + height: "40px", 501 + background: "#2563eb", 502 + color: "white", 503 + display: "flex", 504 + alignItems: "center", 505 + justifyContent: "space-between", 506 + padding: "0 20px", 507 + fontFamily: "system-ui, sans-serif", 508 + fontSize: "14px", 509 + zIndex: "999999", 510 + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 511 + }); 512 + 513 + const text = document.createElement("span"); 514 + text.textContent = "🤖 Browser CLI: " + id; 515 + banner.append(text); 516 + 517 + const closeBtn = document.createElement("button"); 518 + closeBtn.textContent = "✕"; 519 + Object.assign(closeBtn.style, { 520 + background: "none", 521 + border: "none", 522 + color: "white", 523 + fontSize: "20px", 524 + cursor: "pointer", 525 + padding: "0", 526 + width: "30px", 527 + height: "30px", 528 + display: "flex", 529 + alignItems: "center", 530 + justifyContent: "center", 531 + borderRadius: "4px", 532 + transition: "background 0.2s", 533 + }); 534 + 535 + closeBtn.addEventListener( 536 + "mouseover", 537 + () => (closeBtn.style.background = "rgba(255,255,255,0.2)"), 538 + ); 539 + closeBtn.addEventListener( 540 + "mouseout", 541 + () => (closeBtn.style.background = "none"), 542 + ); 543 + closeBtn.addEventListener("click", () => { 544 + browser.runtime.sendMessage({ command: "disableCLI" }); 545 + }); 546 + banner.append(closeBtn); 547 + 548 + document.body.append(banner); 549 + document.body.style.paddingTop = "40px"; 550 + } 551 + }})('${shortId}')`, 552 + }); 553 + 554 + connectNativeHost(); 555 + } 556 + 557 + /** 558 + * Disable extension on a specific tab 559 + * @param {number} tabId 560 + * @param {string} shortId 561 + */ 562 + async function disableOnTab(tabId, shortId) { 563 + managedTabs.delete(shortId); 564 + if (activeTabId === shortId) activeTabId = undefined; 565 + 566 + await browser.tabs.executeScript(tabId, { 567 + code: `(${function () { 568 + const banner = document.querySelector("#browser-cli-banner"); 569 + if (banner) { 570 + banner.remove(); 571 + document.body.style.paddingTop = ""; 572 + } 573 + }})()`, 574 + }); 575 + } 576 + 577 + // ============================================================================ 578 + // Event listeners 579 + // ============================================================================ 580 + 581 + browser.runtime.onMessage.addListener((message, sender, _sendResponse) => { 582 + // Handle disable CLI request 583 + if (message.command === "disableCLI" && sender.tab?.id !== undefined) { 584 + for (const [shortId, tab] of managedTabs) { 585 + if (tab.tabId === sender.tab.id) { 586 + disableOnTab(sender.tab.id, shortId); 587 + break; 588 + } 589 + } 590 + return true; 591 + } 592 + 593 + // Handle content script responses 594 + if (message.messageId && messageHandlers[message.messageId]) { 595 + const handler = messageHandlers[message.messageId]; 596 + delete messageHandlers[message.messageId]; 597 + 598 + if (message.error) { 599 + handler.reject(new Error(message.error)); 600 + } else { 601 + handler.resolve(message.result); 602 + } 603 + return true; 604 + } 605 + 606 + // Handle content script requests for browser-level commands 607 + if (message.bgMessageId) { 608 + const { command, params, bgMessageId } = message; 609 + 610 + // Find the tab ID for this sender 611 + let senderTabShortId; 612 + if (sender.tab?.id !== undefined) { 613 + for (const [shortId, tab] of managedTabs) { 614 + if (tab.tabId === sender.tab.id) { 615 + senderTabShortId = shortId; 616 + break; 617 + } 618 + } 619 + } 620 + 621 + (async () => { 622 + try { 623 + let result; 624 + 625 + switch (command) { 626 + case "navigate": { 627 + result = await navigate(params.url, senderTabShortId); 628 + break; 629 + } 630 + case "back": { 631 + result = await goBack(senderTabShortId); 632 + break; 633 + } 634 + case "forward": { 635 + result = await goForward(senderTabShortId); 636 + break; 637 + } 638 + case "screenshot": { 639 + result = await takeScreenshot(senderTabShortId); 640 + // Save screenshot to file if path provided 641 + if (params.output_path && result.screenshot) { 642 + const dataUrl = result.screenshot; 643 + try { 644 + const saved = await saveScreenshotToFile( 645 + dataUrl, 646 + params.output_path, 647 + ); 648 + result = saved; 649 + } catch (saveError) { 650 + result = { 651 + error: 652 + saveError instanceof Error 653 + ? saveError.message 654 + : String(saveError), 655 + screenshot: dataUrl, 656 + }; 657 + } 658 + } 659 + break; 660 + } 661 + case "new-tab": { 662 + result = await createNewTab(params.url); 663 + break; 664 + } 665 + case "close-tab": { 666 + result = await closeTab(params.tabId || senderTabShortId); 667 + break; 668 + } 669 + case "list-tabs": { 670 + result = await listTabs(); 671 + break; 672 + } 673 + case "download": { 674 + result = await downloadFile(params.url, params.filename); 675 + break; 676 + } 677 + default: { 678 + throw new Error(`Unknown background command: ${command}`); 679 + } 680 + } 681 + 682 + // Send response back to content script 683 + if (sender.tab?.id !== undefined) { 684 + browser.tabs.sendMessage(sender.tab.id, { 685 + bgMessageId, 686 + result, 687 + }); 688 + } 689 + } catch (error) { 690 + if (sender.tab?.id !== undefined) { 691 + browser.tabs.sendMessage(sender.tab.id, { 692 + bgMessageId, 693 + error: error instanceof Error ? error.message : String(error), 694 + }); 695 + } 696 + } 697 + })(); 698 + 699 + return true; 700 + } 701 + 702 + return true; 703 + }); 704 + 705 + browser.tabs.onActivated.addListener(async (activeInfo) => { 706 + for (const [shortId, tab] of managedTabs) { 707 + if (tab.tabId === activeInfo.tabId) { 708 + activeTabId = shortId; 709 + return; 710 + } 711 + } 712 + activeTabId = undefined; 713 + }); 714 + 715 + browser.tabs.onRemoved.addListener((tabId) => { 716 + for (const [shortId, tab] of managedTabs) { 717 + if (tab.tabId === tabId) { 718 + managedTabs.delete(shortId); 719 + if (activeTabId === shortId) activeTabId = undefined; 720 + break; 721 + } 722 + } 723 + }); 724 + 725 + browser.browserAction.onClicked.addListener(async () => { 726 + await createNewTab(); 727 + }); 728 + 729 + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { 730 + if (changeInfo.status === "complete") { 731 + for (const [shortId, managedTab] of managedTabs) { 732 + if (managedTab.tabId === tabId) { 733 + managedTab.url = tab.url || managedTab.url; 734 + managedTab.title = tab.title || managedTab.title; 735 + await enableOnTab(tabId, shortId); 736 + break; 737 + } 738 + } 739 + } 740 + }); 741 + 742 + // Initialize 743 + connectNativeHost();
+1994
skills/browser-cli/extension/content.js
··· 1 + /// <reference types="firefox-webext-browser" /> 2 + 3 + /** 4 + * Content script for Browser CLI Controller 5 + * Optimized API for LLM agents with limited context windows 6 + * 7 + * @typedef {'css' | 'text' | 'aria-label' | 'placeholder'} SelectorType 8 + * 9 + * @typedef {object} ConsoleLog 10 + * @property {string} type - Log type (log, error, warn, info, debug) 11 + * @property {string} message - Log message 12 + * @property {string} timestamp - ISO timestamp 13 + * 14 + * @typedef {object} ElementData 15 + * @property {number} ref - Element reference number 16 + * @property {string} role - Element role (button, link, input, etc.) 17 + * @property {string} name - Accessible name 18 + * @property {string} [value] - Current value (for inputs) 19 + * @property {string[]} attrs - Attributes like [clickable], [disabled] 20 + * @property {Element} domElement - Reference to DOM element 21 + */ 22 + 23 + /** @type {ConsoleLog[]} Store console logs */ 24 + const consoleLogs = []; 25 + /** @type {number} */ 26 + const MAX_CONSOLE_LOGS = 1000; 27 + 28 + /** @type {HTMLDivElement|undefined} Virtual cursor element */ 29 + let virtualCursor; 30 + 31 + /** @type {HTMLInputElement|HTMLTextAreaElement|undefined} Last focused input element */ 32 + let lastFocusedInput; 33 + 34 + /** @type {Map<number, Element>} Current ref to element mapping */ 35 + let currentRefMap = new Map(); 36 + 37 + /** @type {Snapshot|undefined} Last snapshot for diffing */ 38 + let lastSnapshot; 39 + 40 + // ============================================================================ 41 + // Snapshot and Element Classes 42 + // ============================================================================ 43 + 44 + /** 45 + * Element wrapper with LLM-friendly toString 46 + */ 47 + class SnapshotElement { 48 + /** 49 + * @param {ElementData} data 50 + */ 51 + constructor(data) { 52 + /** @type {number} */ 53 + this.ref = data.ref; 54 + /** @type {string} */ 55 + this.role = data.role; 56 + /** @type {string} */ 57 + this.name = data.name; 58 + /** @type {string|undefined} */ 59 + this.value = data.value; 60 + /** @type {string[]} */ 61 + this.attrs = data.attrs; 62 + /** @type {Element} */ 63 + this.domElement = data.domElement; 64 + } 65 + 66 + /** 67 + * LLM-friendly string representation 68 + * @returns {string} 69 + */ 70 + toString() { 71 + let s = `[${this.ref}] ${this.role}`; 72 + if (this.name) s += ` "${this.name}"`; 73 + if (this.attrs.length > 0) s += ` [${this.attrs.join(", ")}]`; 74 + if (this.value !== undefined) s += ` value="${this.value}"`; 75 + return s; 76 + } 77 + 78 + /** 79 + * JSON representation (without DOM element) 80 + * @returns {object} 81 + */ 82 + toJSON() { 83 + return { 84 + ref: this.ref, 85 + role: this.role, 86 + name: this.name, 87 + value: this.value, 88 + attrs: this.attrs, 89 + }; 90 + } 91 + } 92 + 93 + /** 94 + * Snapshot class with filtering and LLM-friendly output 95 + */ 96 + class Snapshot { 97 + /** 98 + * @param {SnapshotElement[]} elements 99 + * @param {string} url 100 + * @param {string} title 101 + */ 102 + constructor(elements, url, title) { 103 + /** @type {SnapshotElement[]} */ 104 + this.elements = elements; 105 + /** @type {string} */ 106 + this.url = url; 107 + /** @type {string} */ 108 + this.title = title; 109 + /** @type {Map<number, SnapshotElement>} */ 110 + this._refMap = new Map(); 111 + for (const el of elements) { 112 + this._refMap.set(el.ref, el); 113 + } 114 + } 115 + 116 + /** 117 + * Find element by ref 118 + * @param {number} ref 119 + * @returns {SnapshotElement|undefined} 120 + */ 121 + find(ref) { 122 + return this._refMap.get(ref); 123 + } 124 + 125 + /** 126 + * Get all buttons 127 + * @returns {SnapshotElement[]} 128 + */ 129 + get buttons() { 130 + return this.elements.filter((e) => e.role === "button"); 131 + } 132 + 133 + /** 134 + * Get all links 135 + * @returns {SnapshotElement[]} 136 + */ 137 + get links() { 138 + return this.elements.filter((e) => e.role === "link"); 139 + } 140 + 141 + /** 142 + * Get all inputs 143 + * @returns {SnapshotElement[]} 144 + */ 145 + get inputs() { 146 + return this.elements.filter( 147 + (e) => 148 + ["input", "textarea", "select"].includes(e.role) || 149 + e.role.startsWith("input["), 150 + ); 151 + } 152 + 153 + /** 154 + * Get all form elements 155 + * @returns {SnapshotElement[]} 156 + */ 157 + get forms() { 158 + return this.elements.filter( 159 + (e) => 160 + ["input", "textarea", "select", "checkbox", "radio"].includes(e.role) || 161 + e.role.startsWith("input["), 162 + ); 163 + } 164 + 165 + /** 166 + * Search by text 167 + * @param {string} text 168 + * @returns {SnapshotElement[]} 169 + */ 170 + search(text) { 171 + const lower = text.toLowerCase(); 172 + return this.elements.filter( 173 + (e) => 174 + e.name?.toLowerCase().includes(lower) || 175 + e.value?.toLowerCase().includes(lower), 176 + ); 177 + } 178 + 179 + /** 180 + * Compare with another snapshot and return differences 181 + * @param {Snapshot} other - Previous snapshot to compare against 182 + * @returns {SnapshotDiff} 183 + */ 184 + diff(other) { 185 + return new SnapshotDiff(other, this); 186 + } 187 + 188 + /** 189 + * LLM-friendly output 190 + * @returns {string} 191 + */ 192 + toString() { 193 + let out = `Page: ${this.title}\nURL: ${this.url}\n\n`; 194 + for (const el of this.elements) { 195 + out += el.toString() + "\n"; 196 + } 197 + return out; 198 + } 199 + 200 + /** 201 + * JSON representation 202 + * @returns {object} 203 + */ 204 + toJSON() { 205 + return { 206 + url: this.url, 207 + title: this.title, 208 + elements: this.elements.map((e) => e.toJSON()), 209 + }; 210 + } 211 + } 212 + 213 + /** 214 + * Diff between two snapshots 215 + */ 216 + class SnapshotDiff { 217 + /** 218 + * @param {Snapshot} before 219 + * @param {Snapshot} after 220 + */ 221 + constructor(before, after) { 222 + /** @type {boolean} */ 223 + this.urlChanged = before.url !== after.url; 224 + /** @type {string|undefined} */ 225 + this.oldUrl = this.urlChanged ? before.url : undefined; 226 + /** @type {string|undefined} */ 227 + this.newUrl = this.urlChanged ? after.url : undefined; 228 + 229 + /** @type {boolean} */ 230 + this.titleChanged = before.title !== after.title; 231 + /** @type {string|undefined} */ 232 + this.oldTitle = this.titleChanged ? before.title : undefined; 233 + /** @type {string|undefined} */ 234 + this.newTitle = this.titleChanged ? after.title : undefined; 235 + 236 + // Build maps by element signature (role + name) for comparison 237 + /** @type {Map<string, SnapshotElement>} */ 238 + const beforeMap = new Map(); 239 + for (const el of before.elements) { 240 + const key = `${el.role}|${el.name}`; 241 + beforeMap.set(key, el); 242 + } 243 + 244 + /** @type {Map<string, SnapshotElement>} */ 245 + const afterMap = new Map(); 246 + for (const el of after.elements) { 247 + const key = `${el.role}|${el.name}`; 248 + afterMap.set(key, el); 249 + } 250 + 251 + /** @type {SnapshotElement[]} */ 252 + this.added = []; 253 + /** @type {SnapshotElement[]} */ 254 + this.removed = []; 255 + /** @type {Array<{element: SnapshotElement, changes: string[]}>} */ 256 + this.changed = []; 257 + 258 + // Find added and changed elements 259 + for (const [key, el] of afterMap) { 260 + const beforeEl = beforeMap.get(key); 261 + if (beforeEl) { 262 + // Check for changes 263 + const changes = []; 264 + if (beforeEl.value !== el.value) { 265 + changes.push( 266 + `value: "${beforeEl.value ?? ""}" → "${el.value ?? ""}"`, 267 + ); 268 + } 269 + const beforeAttrs = beforeEl.attrs.join(","); 270 + const afterAttrs = el.attrs.join(","); 271 + if (beforeAttrs !== afterAttrs) { 272 + changes.push(`attrs: [${beforeAttrs}] → [${afterAttrs}]`); 273 + } 274 + if (changes.length > 0) { 275 + this.changed.push({ element: el, changes }); 276 + } 277 + } else { 278 + this.added.push(el); 279 + } 280 + } 281 + 282 + // Find removed elements 283 + for (const [key, el] of beforeMap) { 284 + if (!afterMap.has(key)) { 285 + this.removed.push(el); 286 + } 287 + } 288 + } 289 + 290 + /** 291 + * Check if anything changed 292 + * @returns {boolean} 293 + */ 294 + get hasChanges() { 295 + return ( 296 + this.urlChanged || 297 + this.titleChanged || 298 + this.added.length > 0 || 299 + this.removed.length > 0 || 300 + this.changed.length > 0 301 + ); 302 + } 303 + 304 + /** 305 + * LLM-friendly output 306 + * @returns {string} 307 + */ 308 + toString() { 309 + if (!this.hasChanges) { 310 + return "No changes"; 311 + } 312 + 313 + const lines = []; 314 + 315 + if (this.urlChanged) { 316 + lines.push(`URL: ${this.oldUrl} → ${this.newUrl}`); 317 + } 318 + if (this.titleChanged) { 319 + lines.push(`Title: "${this.oldTitle}" → "${this.newTitle}"`); 320 + } 321 + 322 + if (this.added.length > 0) { 323 + lines.push("", `Added (${this.added.length}):`); 324 + for (const el of this.added) { 325 + lines.push(` + ${el.toString()}`); 326 + } 327 + } 328 + 329 + if (this.removed.length > 0) { 330 + lines.push("", `Removed (${this.removed.length}):`); 331 + for (const el of this.removed) { 332 + lines.push(` - ${el.toString()}`); 333 + } 334 + } 335 + 336 + if (this.changed.length > 0) { 337 + lines.push("", `Changed (${this.changed.length}):`); 338 + for (const { element, changes } of this.changed) { 339 + lines.push( 340 + ` ~ ${element.toString()}`, 341 + ...changes.map((c) => ` ${c}`), 342 + ); 343 + } 344 + } 345 + 346 + return lines.join("\n"); 347 + } 348 + 349 + /** 350 + * JSON representation 351 + * @returns {object} 352 + */ 353 + toJSON() { 354 + return { 355 + urlChanged: this.urlChanged, 356 + oldUrl: this.oldUrl, 357 + newUrl: this.newUrl, 358 + titleChanged: this.titleChanged, 359 + oldTitle: this.oldTitle, 360 + newTitle: this.newTitle, 361 + added: this.added.map((e) => e.toJSON()), 362 + removed: this.removed.map((e) => e.toJSON()), 363 + changed: this.changed.map(({ element, changes }) => ({ 364 + element: element.toJSON(), 365 + changes, 366 + })), 367 + }; 368 + } 369 + } 370 + 371 + // ============================================================================ 372 + // Virtual Cursor 373 + // ============================================================================ 374 + 375 + /** 376 + * Create and initialize the virtual cursor 377 + */ 378 + function initializeVirtualCursor() { 379 + if (virtualCursor) return; 380 + 381 + virtualCursor = document.createElement("div"); 382 + virtualCursor.id = "browser-cli-cursor"; 383 + virtualCursor.style.cssText = ` 384 + position: fixed; 385 + width: 20px; 386 + height: 20px; 387 + pointer-events: none; 388 + z-index: 999999; 389 + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 390 + opacity: 1; 391 + `; 392 + 393 + const inner = document.createElement("div"); 394 + inner.style.cssText = ` 395 + position: absolute; 396 + top: 50%; 397 + left: 50%; 398 + transform: translate(-50%, -50%); 399 + width: 8px; 400 + height: 8px; 401 + background: #ff6b6b; 402 + border-radius: 50%; 403 + box-shadow: 0 0 10px rgba(255, 107, 107, 0.5); 404 + `; 405 + 406 + const outer = document.createElement("div"); 407 + outer.style.cssText = ` 408 + position: absolute; 409 + top: 50%; 410 + left: 50%; 411 + transform: translate(-50%, -50%); 412 + width: 20px; 413 + height: 20px; 414 + border: 2px solid #ff6b6b; 415 + border-radius: 50%; 416 + animation: browser-cli-pulse 1.5s ease-out infinite; 417 + `; 418 + 419 + const style = document.createElement("style"); 420 + style.textContent = ` 421 + @keyframes browser-cli-pulse { 422 + 0% { transform: translate(-50%, -50%) scale(1); opacity: 1; } 423 + 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; } 424 + } 425 + @keyframes browser-cli-click { 426 + 0% { transform: translate(-50%, -50%) scale(1); } 427 + 50% { transform: translate(-50%, -50%) scale(0.8); } 428 + 100% { transform: translate(-50%, -50%) scale(1); } 429 + } 430 + `; 431 + 432 + virtualCursor.append(inner); 433 + virtualCursor.append(outer); 434 + document.head.append(style); 435 + document.body.append(virtualCursor); 436 + } 437 + 438 + /** 439 + * Move virtual cursor to element with animation 440 + * @param {Element} element - Target element 441 + * @param {boolean} [showClick=false] - Whether to show click animation 442 + */ 443 + function moveCursorToElement(element, showClick = false) { 444 + if (!virtualCursor) { 445 + initializeVirtualCursor(); 446 + } 447 + 448 + const rect = element.getBoundingClientRect(); 449 + const x = rect.left + rect.width / 2; 450 + const y = rect.top + rect.height / 2; 451 + 452 + if (virtualCursor) { 453 + virtualCursor.style.left = `${x}px`; 454 + virtualCursor.style.top = `${y}px`; 455 + virtualCursor.style.opacity = "1"; 456 + 457 + if (showClick) { 458 + const inner = virtualCursor.querySelector("div:first-child"); 459 + if (inner && inner instanceof HTMLElement) { 460 + inner.style.animation = "browser-cli-click 0.3s ease-out"; 461 + setTimeout(() => { 462 + inner.style.animation = ""; 463 + }, 300); 464 + } 465 + } 466 + } 467 + } 468 + 469 + // ============================================================================ 470 + // Console Override 471 + // ============================================================================ 472 + 473 + /** 474 + * Inject console override into page context to capture all logs 475 + */ 476 + function injectConsoleOverride() { 477 + const script = document.createElement("script"); 478 + script.textContent = ` 479 + (function() { 480 + const consoleMethods = ['log', 'error', 'warn', 'info', 'debug']; 481 + consoleMethods.forEach(method => { 482 + const original = console[method]; 483 + console[method] = function(...args) { 484 + window.postMessage({ 485 + type: 'browser-cli-console', 486 + method: method, 487 + args: args.map(arg => { 488 + try { 489 + return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); 490 + } catch { 491 + return '[Circular reference]'; 492 + } 493 + }), 494 + timestamp: new Date().toISOString() 495 + }, '*'); 496 + original.apply(console, args); 497 + }; 498 + }); 499 + })(); 500 + `; 501 + (document.head || document.documentElement).append(script); 502 + script.remove(); 503 + } 504 + 505 + window.addEventListener("message", (event) => { 506 + if (event.source !== window) return; 507 + 508 + if (event.data && event.data.type === "browser-cli-console") { 509 + consoleLogs.push({ 510 + type: event.data.method, 511 + message: event.data.args.join(" "), 512 + timestamp: event.data.timestamp, 513 + }); 514 + 515 + if (consoleLogs.length > MAX_CONSOLE_LOGS) { 516 + consoleLogs.shift(); 517 + } 518 + } 519 + }); 520 + 521 + injectConsoleOverride(); 522 + 523 + /** @type {Array<keyof Console>} */ 524 + const consoleMethods = /** @type {any} */ ([ 525 + "log", 526 + "error", 527 + "warn", 528 + "info", 529 + "debug", 530 + ]); 531 + for (const method of consoleMethods) { 532 + // @ts-ignore 533 + const original = console[method]; 534 + // @ts-ignore 535 + console[method] = function (.../** @type {any[]} */ args) { 536 + consoleLogs.push({ 537 + type: method, 538 + message: args 539 + .map((arg) => { 540 + try { 541 + return typeof arg === "object" ? JSON.stringify(arg) : String(arg); 542 + } catch { 543 + return String(arg); 544 + } 545 + }) 546 + .join(" "), 547 + timestamp: new Date().toISOString(), 548 + }); 549 + 550 + if (consoleLogs.length > MAX_CONSOLE_LOGS) { 551 + consoleLogs.shift(); 552 + } 553 + 554 + original.apply(console, args); 555 + }; 556 + } 557 + 558 + // ============================================================================ 559 + // Element Finding Functions 560 + // ============================================================================ 561 + 562 + /** 563 + * Find element by ref number 564 + * @param {number} ref - Reference number from snapshot 565 + * @returns {Element} Found element 566 + */ 567 + function findByRef(ref) { 568 + const element = currentRefMap.get(ref); 569 + if (element) { 570 + // Verify element is still in DOM 571 + if (document.contains(element)) { 572 + return element; 573 + } 574 + // Element was removed, clear stale ref 575 + currentRefMap.delete(ref); 576 + } 577 + 578 + // Build helpful error message with available refs 579 + const available = []; 580 + for (const [r, el] of currentRefMap) { 581 + if (!document.contains(el)) { 582 + continue; 583 + } 584 + const role = getElementRole(el); 585 + const name = getAccessibleName(el); 586 + available.push(` [${r}] ${role}${name ? ` "${name}"` : ""}`); 587 + if (available.length >= 5) { 588 + available.push(" ..."); 589 + break; 590 + } 591 + } 592 + 593 + let msg = `Element [${ref}] not found.`; 594 + msg += 595 + available.length > 0 596 + ? ` Available:\n${available.join("\n")}` 597 + : " No refs available. Call snap() first."; 598 + throw new Error(msg); 599 + } 600 + 601 + /** 602 + * Find element by CSS selector 603 + * @param {string} selector - CSS selector 604 + * @returns {Element} Found element 605 + */ 606 + function findBySelector(selector) { 607 + try { 608 + const element = document.querySelector(selector); 609 + if (element) return element; 610 + throw new Error(`No element matches CSS selector: ${selector}`); 611 + } catch (error) { 612 + if (error instanceof Error && error.name === "SyntaxError") { 613 + throw new Error(`Invalid CSS selector: ${selector}`); 614 + } 615 + throw error; 616 + } 617 + } 618 + 619 + /** 620 + * Find element by text content 621 + * @param {string} text - Text to search for 622 + * @returns {Element} Found element 623 + */ 624 + function findByText(text) { 625 + const xpath = text.includes('"') 626 + ? `//*[contains(text(), '${text}')]` 627 + : `//*[contains(text(), "${text}")]`; 628 + 629 + const result = document.evaluate( 630 + xpath, 631 + document, 632 + undefined, 633 + XPathResult.FIRST_ORDERED_NODE_TYPE, 634 + ); 635 + if (result.singleNodeValue && result.singleNodeValue instanceof Element) { 636 + return result.singleNodeValue; 637 + } 638 + throw new Error(`No element found with text: ${text}`); 639 + } 640 + 641 + /** 642 + * Find element by aria-label 643 + * @param {string} label - ARIA label to search for 644 + * @returns {Element} Found element 645 + */ 646 + function findByAriaLabel(label) { 647 + const element = document.querySelector(`[aria-label="${CSS.escape(label)}"]`); 648 + if (element) return element; 649 + throw new Error(`No element found with aria-label: ${label}`); 650 + } 651 + 652 + /** 653 + * Find element by placeholder 654 + * @param {string} placeholder - Placeholder text to search for 655 + * @returns {Element} Found element 656 + */ 657 + function findByPlaceholder(placeholder) { 658 + const element = document.querySelector( 659 + `[placeholder="${CSS.escape(placeholder)}"]`, 660 + ); 661 + if (element) return element; 662 + throw new Error(`No element found with placeholder: ${placeholder}`); 663 + } 664 + 665 + /** 666 + * Find element based on selector type or ref 667 + * @param {string|number} selector - The selector value or ref number 668 + * @param {SelectorType} [selectorType='css'] - Type of selector 669 + * @returns {Element} Found element 670 + */ 671 + function find(selector, selectorType = "css") { 672 + // If selector is a number, treat as ref 673 + if (typeof selector === "number") { 674 + return findByRef(selector); 675 + } 676 + 677 + switch (selectorType) { 678 + case "css": { 679 + return findBySelector(selector); 680 + } 681 + case "text": { 682 + return findByText(selector); 683 + } 684 + case "aria-label": { 685 + return findByAriaLabel(selector); 686 + } 687 + case "placeholder": { 688 + return findByPlaceholder(selector); 689 + } 690 + default: { 691 + throw new Error(`Unknown selector type: ${selectorType}`); 692 + } 693 + } 694 + } 695 + 696 + // ============================================================================ 697 + // Snapshot Generation 698 + // ============================================================================ 699 + 700 + /** 701 + * Get the role of an element 702 + * @param {Element} element 703 + * @returns {string} 704 + */ 705 + function getElementRole(element) { 706 + const explicitRole = element.getAttribute("role"); 707 + if (explicitRole) return explicitRole; 708 + 709 + const tag = element.tagName.toLowerCase(); 710 + 711 + // Map common tags to roles 712 + const tagRoleMap = { 713 + a: "link", 714 + button: "button", 715 + input: "input", 716 + textarea: "textarea", 717 + select: "select", 718 + img: "img", 719 + h1: "heading", 720 + h2: "heading", 721 + h3: "heading", 722 + h4: "heading", 723 + h5: "heading", 724 + h6: "heading", 725 + nav: "navigation", 726 + main: "main", 727 + header: "banner", 728 + footer: "contentinfo", 729 + aside: "complementary", 730 + form: "form", 731 + table: "table", 732 + ul: "list", 733 + ol: "list", 734 + li: "listitem", 735 + }; 736 + 737 + if (tag === "input") { 738 + const type = element.getAttribute("type") || "text"; 739 + if (type === "checkbox") return "checkbox"; 740 + if (type === "radio") return "radio"; 741 + if (type === "submit" || type === "button") return "button"; 742 + return `input[${type}]`; 743 + } 744 + 745 + return /** @type {Record<string, string>} */ (tagRoleMap)[tag] || tag; 746 + } 747 + 748 + /** 749 + * Get accessible name of an element 750 + * @param {Element} element 751 + * @returns {string} 752 + */ 753 + function getAccessibleName(element) { 754 + // aria-label takes precedence 755 + const ariaLabel = element.getAttribute("aria-label"); 756 + if (ariaLabel) return ariaLabel; 757 + 758 + // aria-labelledby 759 + const labelledBy = element.getAttribute("aria-labelledby"); 760 + if (labelledBy) { 761 + const labelEl = document.querySelector(`#${CSS.escape(labelledBy)}`); 762 + if (labelEl) return labelEl.textContent?.trim() || ""; 763 + } 764 + 765 + // For inputs, check associated label 766 + if ( 767 + element instanceof HTMLInputElement || 768 + element instanceof HTMLTextAreaElement || 769 + element instanceof HTMLSelectElement 770 + ) { 771 + // Check for label wrapping the input 772 + const parentLabel = element.closest("label"); 773 + if (parentLabel) { 774 + const text = parentLabel.textContent?.trim() || ""; 775 + return text; 776 + } 777 + 778 + // Check for label with for attribute 779 + if (element.id) { 780 + const label = document.querySelector( 781 + `label[for="${CSS.escape(element.id)}"]`, 782 + ); 783 + if (label) return label.textContent?.trim() || ""; 784 + } 785 + 786 + // Fall back to placeholder 787 + const placeholder = element.getAttribute("placeholder"); 788 + if (placeholder) return placeholder; 789 + } 790 + 791 + // alt text for images 792 + if (element instanceof HTMLImageElement) { 793 + return element.alt || ""; 794 + } 795 + 796 + // title attribute 797 + const title = element.getAttribute("title"); 798 + if (title) return title; 799 + 800 + // Text content for buttons, links, etc. 801 + const tag = element.tagName.toLowerCase(); 802 + if (["button", "a", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) { 803 + // Get text content excluding script/style elements 804 + let text = ""; 805 + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { 806 + acceptNode: (node) => { 807 + const parent = node.parentElement; 808 + if ( 809 + parent && 810 + ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName) 811 + ) { 812 + return NodeFilter.FILTER_REJECT; 813 + } 814 + return NodeFilter.FILTER_ACCEPT; 815 + }, 816 + }); 817 + while (walker.nextNode()) { 818 + text += walker.currentNode.textContent; 819 + } 820 + text = text.trim(); 821 + // Filter out CSS-looking content (starts with . or # followed by { or contains {) 822 + if (text && /^\s*[.#]?[\w-]+\s*\{/.test(text)) { 823 + return ""; 824 + } 825 + return text; 826 + } 827 + 828 + return ""; 829 + } 830 + 831 + /** 832 + * Get element attributes for display 833 + * @param {Element} element 834 + * @returns {string[]} 835 + */ 836 + function getElementAttrs(element) { 837 + const attrs = []; 838 + 839 + // Disabled state 840 + if ( 841 + element.hasAttribute("disabled") || 842 + element.getAttribute("aria-disabled") === "true" 843 + ) { 844 + attrs.push("disabled"); 845 + } 846 + 847 + // Checked state for checkboxes/radios 848 + if ( 849 + element instanceof HTMLInputElement && 850 + (element.type === "checkbox" || element.type === "radio") && 851 + element.checked 852 + ) { 853 + attrs.push("checked"); 854 + } 855 + 856 + // Required 857 + if ( 858 + element.hasAttribute("required") || 859 + element.getAttribute("aria-required") === "true" 860 + ) { 861 + attrs.push("required"); 862 + } 863 + 864 + // Expanded/collapsed 865 + const expanded = element.getAttribute("aria-expanded"); 866 + if (expanded === "true") attrs.push("expanded"); 867 + if (expanded === "false") attrs.push("collapsed"); 868 + 869 + // Readonly 870 + if (element.hasAttribute("readonly")) { 871 + attrs.push("readonly"); 872 + } 873 + 874 + // Clickable (for non-obvious elements) 875 + const tag = element.tagName.toLowerCase(); 876 + if ( 877 + !["a", "button", "input", "select", "textarea"].includes(tag) && 878 + (element.getAttribute("onclick") || 879 + element.getAttribute("role") === "button" || 880 + window.getComputedStyle(element).cursor === "pointer") 881 + ) { 882 + attrs.push("clickable"); 883 + } 884 + 885 + return attrs; 886 + } 887 + 888 + /** 889 + * Get element value 890 + * @param {Element} element 891 + * @returns {string|undefined} 892 + */ 893 + function getElementValue(element) { 894 + if (element instanceof HTMLInputElement) { 895 + if (element.type === "checkbox" || element.type === "radio") { 896 + return; // Use checked attr instead 897 + } 898 + if (element.type === "password") { 899 + return element.value ? "••••••" : ""; 900 + } 901 + return element.value; 902 + } 903 + 904 + if (element instanceof HTMLTextAreaElement) { 905 + return element.value; 906 + } 907 + 908 + if (element instanceof HTMLSelectElement) { 909 + const selected = element.options[element.selectedIndex]; 910 + return selected ? selected.text : ""; 911 + } 912 + } 913 + 914 + /** 915 + * Check if element should be included in snapshot 916 + * @param {Element} element 917 + * @returns {boolean} 918 + */ 919 + function shouldIncludeElement(element) { 920 + // Skip hidden elements 921 + const style = window.getComputedStyle(element); 922 + if (style.display === "none" || style.visibility === "hidden") { 923 + return false; 924 + } 925 + 926 + // Skip zero-size elements 927 + const rect = element.getBoundingClientRect(); 928 + if (rect.width === 0 && rect.height === 0) { 929 + return false; 930 + } 931 + 932 + // Skip script, style, noscript, template 933 + const tag = element.tagName.toLowerCase(); 934 + if ( 935 + ["script", "style", "noscript", "template", "svg", "path"].includes(tag) 936 + ) { 937 + return false; 938 + } 939 + 940 + // Include interactive elements 941 + if (["a", "button", "input", "textarea", "select"].includes(tag)) { 942 + return true; 943 + } 944 + 945 + // Include elements with explicit roles 946 + if (element.hasAttribute("role")) { 947 + const role = element.getAttribute("role"); 948 + if (role && !["presentation", "none"].includes(role)) return true; 949 + } 950 + 951 + // Include headings 952 + if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) { 953 + return true; 954 + } 955 + 956 + // Include landmarks 957 + if (["nav", "main", "header", "footer", "aside", "form"].includes(tag)) { 958 + return true; 959 + } 960 + 961 + // Include images with alt text 962 + if (tag === "img" && element.getAttribute("alt")) { 963 + return true; 964 + } 965 + 966 + // Include elements with onclick attribute 967 + if (element.getAttribute("onclick")) { 968 + return true; 969 + } 970 + 971 + // Include elements with aria-label 972 + if (element.hasAttribute("aria-label")) { 973 + return true; 974 + } 975 + 976 + // For cursor:pointer elements, only include if they: 977 + // 1. Have a meaningful accessible name (not empty, not just whitespace) 978 + // 2. Are not generic wrapper tags (div, span, g, rect, circle, etc.) 979 + // 3. Or have tabindex making them keyboard-focusable 980 + if (window.getComputedStyle(element).cursor === "pointer") { 981 + const genericTags = [ 982 + "div", 983 + "span", 984 + "g", 985 + "rect", 986 + "circle", 987 + "path", 988 + "svg", 989 + "text", 990 + "br", 991 + "hr", 992 + "b", 993 + "i", 994 + "em", 995 + "strong", 996 + ]; 997 + if (genericTags.includes(tag)) { 998 + // Only include generic clickable if it has tabindex or meaningful name 999 + if ( 1000 + element.hasAttribute("tabindex") && 1001 + element.getAttribute("tabindex") !== "-1" 1002 + ) { 1003 + return true; 1004 + } 1005 + // Check for meaningful accessible name 1006 + const name = getAccessibleName(element); 1007 + if (name && name.length > 0 && name.length < 200 && !/^\s*$/.test(name)) { 1008 + return true; 1009 + } 1010 + return false; 1011 + } 1012 + return true; 1013 + } 1014 + 1015 + return false; 1016 + } 1017 + 1018 + /** 1019 + * @typedef {object} SnapOptions 1020 + * @property {boolean} [forms] - Only form elements 1021 + * @property {boolean} [links] - Only links 1022 + * @property {boolean} [buttons] - Only buttons 1023 + * @property {string} [text] - Filter by text content 1024 + * @property {number} [near] - Elements near ref 1025 + */ 1026 + 1027 + /** 1028 + * Generate snapshot with optional filtering 1029 + * @param {SnapOptions} [options] 1030 + * @returns {Snapshot} 1031 + */ 1032 + function generateSnapshot(options = {}) { 1033 + const elements = []; 1034 + let ref = 1; 1035 + 1036 + // Clear old ref map 1037 + currentRefMap = new Map(); 1038 + 1039 + // Collect all relevant elements 1040 + const allElements = document.body.querySelectorAll("*"); 1041 + 1042 + for (const element of allElements) { 1043 + if (!shouldIncludeElement(element)) continue; 1044 + 1045 + const role = getElementRole(element); 1046 + const name = getAccessibleName(element); 1047 + const attrs = getElementAttrs(element); 1048 + const value = getElementValue(element); 1049 + 1050 + // Apply filters 1051 + if ( 1052 + options.forms && 1053 + !["input", "textarea", "select", "checkbox", "radio"].includes(role) && 1054 + !role.startsWith("input[") 1055 + ) { 1056 + continue; 1057 + } 1058 + 1059 + if (options.links && role !== "link") continue; 1060 + if (options.buttons && role !== "button") continue; 1061 + 1062 + if (options.text) { 1063 + const searchText = options.text.toLowerCase(); 1064 + if ( 1065 + !name.toLowerCase().includes(searchText) && 1066 + !(value && value.toLowerCase().includes(searchText)) 1067 + ) { 1068 + continue; 1069 + } 1070 + } 1071 + 1072 + const snapshotElement = new SnapshotElement({ 1073 + ref, 1074 + role, 1075 + name, 1076 + value, 1077 + attrs, 1078 + domElement: element, 1079 + }); 1080 + 1081 + elements.push(snapshotElement); 1082 + currentRefMap.set(ref, element); 1083 + ref++; 1084 + } 1085 + 1086 + // Handle "near" filter - find elements near a reference element 1087 + if (options.near === undefined) { 1088 + return new Snapshot(elements, window.location.href, document.title); 1089 + } 1090 + 1091 + const nearElement = currentRefMap.get(options.near); 1092 + if (!nearElement) { 1093 + return new Snapshot(elements, window.location.href, document.title); 1094 + } 1095 + 1096 + const nearRect = nearElement.getBoundingClientRect(); 1097 + const threshold = 200; // pixels 1098 + 1099 + const filtered = elements.filter((el) => { 1100 + const rect = el.domElement.getBoundingClientRect(); 1101 + const distance = Math.hypot( 1102 + rect.left - nearRect.left, 1103 + rect.top - nearRect.top, 1104 + ); 1105 + return distance < threshold; 1106 + }); 1107 + 1108 + return new Snapshot(filtered, window.location.href, document.title); 1109 + } 1110 + 1111 + // ============================================================================ 1112 + // JS API - Main Functions 1113 + // ============================================================================ 1114 + 1115 + /** 1116 + * Click an element 1117 + * @param {string|number} selector - Element selector or ref number 1118 + * @param {SelectorType|{double?: boolean}} [selectorTypeOrOptions='css'] 1119 + * @returns {Promise<{clicked: string|number}>} 1120 + */ 1121 + async function click(selector, selectorTypeOrOptions = "css") { 1122 + let selectorType = "css"; 1123 + let double = false; 1124 + 1125 + if (typeof selectorTypeOrOptions === "object") { 1126 + double = selectorTypeOrOptions.double || false; 1127 + } else { 1128 + selectorType = selectorTypeOrOptions; 1129 + } 1130 + 1131 + const element = /** @type {HTMLElement} */ ( 1132 + find(selector, /** @type {SelectorType} */ (selectorType)) 1133 + ); 1134 + moveCursorToElement(element, true); 1135 + await new Promise((resolve) => setTimeout(resolve, 300)); 1136 + 1137 + if (double) { 1138 + element.dispatchEvent( 1139 + new MouseEvent("dblclick", { 1140 + view: window, 1141 + bubbles: true, 1142 + cancelable: true, 1143 + }), 1144 + ); 1145 + } else { 1146 + element.click(); 1147 + } 1148 + 1149 + if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") { 1150 + lastFocusedInput = /** @type {HTMLInputElement|HTMLTextAreaElement} */ ( 1151 + element 1152 + ); 1153 + } 1154 + 1155 + return { clicked: selector }; 1156 + } 1157 + 1158 + /** 1159 + * Type text into an element 1160 + * @param {string|number} selector - Element selector or ref number 1161 + * @param {string} text - Text to type 1162 + * @param {SelectorType|{clear?: boolean}} [selectorTypeOrOptions='css'] 1163 + * @returns {Promise<{typed: string, into: string|number}>} 1164 + */ 1165 + async function type(selector, text, selectorTypeOrOptions = "css") { 1166 + let selectorType = "css"; 1167 + let clear = false; 1168 + 1169 + if (typeof selectorTypeOrOptions === "object") { 1170 + clear = selectorTypeOrOptions.clear || false; 1171 + } else { 1172 + selectorType = selectorTypeOrOptions; 1173 + } 1174 + 1175 + const element = /** @type {HTMLInputElement|HTMLTextAreaElement} */ ( 1176 + find(selector, /** @type {SelectorType} */ (selectorType)) 1177 + ); 1178 + 1179 + if (element.tagName !== "INPUT" && element.tagName !== "TEXTAREA") { 1180 + throw new Error( 1181 + `Element is not an input field. Found: <${element.tagName.toLowerCase()}>`, 1182 + ); 1183 + } 1184 + 1185 + moveCursorToElement(element); 1186 + await new Promise((resolve) => setTimeout(resolve, 300)); 1187 + element.focus(); 1188 + lastFocusedInput = element; 1189 + 1190 + if (clear) { 1191 + element.value = ""; 1192 + } 1193 + 1194 + element.value = clear ? text : element.value + text; 1195 + element.dispatchEvent(new Event("input", { bubbles: true })); 1196 + element.dispatchEvent(new Event("change", { bubbles: true })); 1197 + 1198 + return { typed: text, into: selector }; 1199 + } 1200 + 1201 + /** 1202 + * Hover over an element 1203 + * @param {string|number} selector - Element selector or ref number 1204 + * @param {SelectorType} [selectorType='css'] - Type of selector 1205 + * @returns {Promise<{hovered: string|number}>} 1206 + */ 1207 + async function hover(selector, selectorType = "css") { 1208 + const element = find(selector, selectorType); 1209 + moveCursorToElement(element); 1210 + await new Promise((resolve) => setTimeout(resolve, 300)); 1211 + 1212 + element.dispatchEvent( 1213 + new MouseEvent("mouseenter", { 1214 + view: window, 1215 + bubbles: true, 1216 + cancelable: true, 1217 + }), 1218 + ); 1219 + element.dispatchEvent( 1220 + new MouseEvent("mouseover", { 1221 + view: window, 1222 + bubbles: true, 1223 + cancelable: true, 1224 + }), 1225 + ); 1226 + 1227 + return { hovered: selector }; 1228 + } 1229 + 1230 + /** 1231 + * Drag from one element to another 1232 + * @param {string|number} startSelector - Start element selector or ref 1233 + * @param {string|number} endSelector - End element selector or ref 1234 + * @param {SelectorType} [selectorType='css'] - Type of selector 1235 + * @returns {Promise<{dragged: string|number, to: string|number}>} 1236 + */ 1237 + async function drag(startSelector, endSelector, selectorType = "css") { 1238 + const startElement = find(startSelector, selectorType); 1239 + const endElement = find(endSelector, selectorType); 1240 + 1241 + const startRect = startElement.getBoundingClientRect(); 1242 + const endRect = endElement.getBoundingClientRect(); 1243 + const startX = startRect.left + startRect.width / 2; 1244 + const startY = startRect.top + startRect.height / 2; 1245 + const endX = endRect.left + endRect.width / 2; 1246 + const endY = endRect.top + endRect.height / 2; 1247 + 1248 + moveCursorToElement(startElement, true); 1249 + await new Promise((resolve) => setTimeout(resolve, 300)); 1250 + 1251 + const dataTransfer = new DataTransfer(); 1252 + dataTransfer.effectAllowed = "all"; 1253 + dataTransfer.dropEffect = "move"; 1254 + 1255 + startElement.dispatchEvent( 1256 + new MouseEvent("mousedown", { 1257 + view: window, 1258 + bubbles: true, 1259 + cancelable: true, 1260 + clientX: startX, 1261 + clientY: startY, 1262 + }), 1263 + ); 1264 + 1265 + startElement.dispatchEvent( 1266 + new DragEvent("dragstart", { 1267 + view: window, 1268 + bubbles: true, 1269 + cancelable: true, 1270 + clientX: startX, 1271 + clientY: startY, 1272 + dataTransfer, 1273 + }), 1274 + ); 1275 + 1276 + if (virtualCursor) { 1277 + virtualCursor.style.transition = "all 0.8s cubic-bezier(0.4, 0, 0.2, 1)"; 1278 + moveCursorToElement(endElement); 1279 + await new Promise((resolve) => setTimeout(resolve, 800)); 1280 + } 1281 + 1282 + endElement.dispatchEvent( 1283 + new DragEvent("dragenter", { 1284 + view: window, 1285 + bubbles: true, 1286 + cancelable: true, 1287 + clientX: endX, 1288 + clientY: endY, 1289 + dataTransfer, 1290 + }), 1291 + ); 1292 + 1293 + endElement.dispatchEvent( 1294 + new DragEvent("dragover", { 1295 + view: window, 1296 + bubbles: true, 1297 + cancelable: true, 1298 + clientX: endX, 1299 + clientY: endY, 1300 + dataTransfer, 1301 + }), 1302 + ); 1303 + 1304 + await new Promise((resolve) => setTimeout(resolve, 50)); 1305 + 1306 + endElement.dispatchEvent( 1307 + new DragEvent("drop", { 1308 + view: window, 1309 + bubbles: true, 1310 + cancelable: true, 1311 + clientX: endX, 1312 + clientY: endY, 1313 + dataTransfer, 1314 + }), 1315 + ); 1316 + 1317 + startElement.dispatchEvent( 1318 + new DragEvent("dragend", { 1319 + view: window, 1320 + bubbles: true, 1321 + cancelable: true, 1322 + clientX: endX, 1323 + clientY: endY, 1324 + dataTransfer, 1325 + }), 1326 + ); 1327 + 1328 + if (virtualCursor) { 1329 + virtualCursor.style.transition = "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"; 1330 + } 1331 + 1332 + return { dragged: startSelector, to: endSelector }; 1333 + } 1334 + 1335 + /** 1336 + * Select an option in a dropdown 1337 + * @param {string|number} selector - Select element selector or ref 1338 + * @param {string} option - Option value to select 1339 + * @param {SelectorType} [selectorType='css'] - Type of selector 1340 + * @returns {Promise<{selected: string, in: string|number}>} 1341 + */ 1342 + async function select(selector, option, selectorType = "css") { 1343 + const element = /** @type {HTMLSelectElement} */ ( 1344 + find(selector, selectorType) 1345 + ); 1346 + if (element.tagName !== "SELECT") { 1347 + throw new Error( 1348 + `Element is not a select field. Found: <${element.tagName.toLowerCase()}>`, 1349 + ); 1350 + } 1351 + moveCursorToElement(element, true); 1352 + await new Promise((resolve) => setTimeout(resolve, 300)); 1353 + element.value = option; 1354 + element.dispatchEvent(new Event("change", { bubbles: true })); 1355 + 1356 + return { selected: option, in: selector }; 1357 + } 1358 + 1359 + /** 1360 + * Press a keyboard key 1361 + * @param {string} keyName - Key to press 1362 + * @returns {Snapshot} 1363 + */ 1364 + /** @type {Record<string, {code: string, keyCode: number}>} */ 1365 + const SPECIAL_KEYS = { 1366 + Enter: { code: "Enter", keyCode: 13 }, 1367 + Tab: { code: "Tab", keyCode: 9 }, 1368 + Escape: { code: "Escape", keyCode: 27 }, 1369 + Backspace: { code: "Backspace", keyCode: 8 }, 1370 + Delete: { code: "Delete", keyCode: 46 }, 1371 + ArrowUp: { code: "ArrowUp", keyCode: 38 }, 1372 + ArrowDown: { code: "ArrowDown", keyCode: 40 }, 1373 + ArrowLeft: { code: "ArrowLeft", keyCode: 37 }, 1374 + ArrowRight: { code: "ArrowRight", keyCode: 39 }, 1375 + Home: { code: "Home", keyCode: 36 }, 1376 + End: { code: "End", keyCode: 35 }, 1377 + PageUp: { code: "PageUp", keyCode: 33 }, 1378 + PageDown: { code: "PageDown", keyCode: 34 }, 1379 + " ": { code: "Space", keyCode: 32 }, 1380 + Space: { code: "Space", keyCode: 32 }, 1381 + }; 1382 + 1383 + const TEXT_INPUT_TYPES = new Set([ 1384 + "text", 1385 + "search", 1386 + "email", 1387 + "password", 1388 + "tel", 1389 + "url", 1390 + "number", 1391 + ]); 1392 + 1393 + /** 1394 + * Build keyboard event options for a key 1395 + * @param {string} keyName 1396 + * @returns {{key: string, code: string, keyCode: number, which: number, bubbles: boolean, cancelable: boolean, composed: boolean}} 1397 + */ 1398 + function buildKeyEventOptions(keyName) { 1399 + let code = keyName; 1400 + let keyCode = 0; 1401 + let key = keyName; 1402 + 1403 + if (SPECIAL_KEYS[keyName]) { 1404 + code = SPECIAL_KEYS[keyName].code; 1405 + keyCode = SPECIAL_KEYS[keyName].keyCode; 1406 + key = keyName === "Space" ? " " : keyName; 1407 + } else if (keyName.length === 1) { 1408 + keyCode = keyName.codePointAt(0) || 0; 1409 + if (keyName >= "a" && keyName <= "z") { 1410 + code = `Key${keyName.toUpperCase()}`; 1411 + keyCode = keyName.toUpperCase().codePointAt(0) || 0; 1412 + } else if (keyName >= "A" && keyName <= "Z") { 1413 + code = `Key${keyName}`; 1414 + } else if (keyName >= "0" && keyName <= "9") { 1415 + code = `Digit${keyName}`; 1416 + } 1417 + } else { 1418 + code = keyName.charAt(0).toUpperCase() + keyName.slice(1); 1419 + } 1420 + 1421 + return { 1422 + key, 1423 + code, 1424 + keyCode, 1425 + which: keyCode, 1426 + bubbles: true, 1427 + cancelable: true, 1428 + composed: true, 1429 + }; 1430 + } 1431 + 1432 + /** 1433 + * Check if element is a text-editable input 1434 + * @param {Element} element 1435 + * @returns {boolean} 1436 + */ 1437 + function isTextInput(element) { 1438 + if (element.tagName === "TEXTAREA") { 1439 + return true; 1440 + } 1441 + if (element.tagName === "INPUT") { 1442 + const type = 1443 + /** @type {HTMLInputElement} */ (element).type?.toLowerCase() || "text"; 1444 + return TEXT_INPUT_TYPES.has(type); 1445 + } 1446 + return false; 1447 + } 1448 + 1449 + /** 1450 + * Insert text at cursor position in input/textarea 1451 + * @param {HTMLInputElement|HTMLTextAreaElement} element 1452 + * @param {string} text 1453 + */ 1454 + function insertTextAtCursor(element, text) { 1455 + const start = element.selectionStart || 0; 1456 + const end = element.selectionEnd || 0; 1457 + element.value = 1458 + element.value.slice(0, start) + text + element.value.slice(end); 1459 + element.selectionStart = element.selectionEnd = start + text.length; 1460 + element.dispatchEvent( 1461 + new Event("input", { bubbles: true, cancelable: true }), 1462 + ); 1463 + } 1464 + 1465 + /** 1466 + * Try to submit the form containing an input 1467 + * @param {HTMLInputElement} input 1468 + * @returns {boolean} True if form was submitted 1469 + */ 1470 + function trySubmitForm(input) { 1471 + const form = input.form; 1472 + if (!form) { 1473 + return false; 1474 + } 1475 + 1476 + // Try requestSubmit first (triggers submit event handlers) 1477 + if (typeof form.requestSubmit === "function") { 1478 + try { 1479 + form.requestSubmit(); 1480 + return true; 1481 + } catch { 1482 + // requestSubmit can throw if form is invalid, fall through 1483 + } 1484 + } 1485 + 1486 + // Fallback: find submit button and click it 1487 + const submitButton = form.querySelector( 1488 + 'button[type="submit"], input[type="submit"], button:not([type])', 1489 + ); 1490 + if (submitButton) { 1491 + /** @type {HTMLButtonElement|HTMLInputElement} */ (submitButton).click(); 1492 + return true; 1493 + } 1494 + 1495 + // Last resort: dispatch submit event 1496 + form.dispatchEvent( 1497 + new SubmitEvent("submit", { bubbles: true, cancelable: true }), 1498 + ); 1499 + return true; 1500 + } 1501 + 1502 + /** 1503 + * Handle Tab key navigation 1504 + * @param {Element} activeElement 1505 + */ 1506 + function handleTabKey(activeElement) { 1507 + const focusableElements = /** @type {HTMLElement[]} */ ([ 1508 + ...document.querySelectorAll( 1509 + 'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])', 1510 + ), 1511 + ]).filter( 1512 + (el) => 1513 + !(/** @type {HTMLButtonElement|HTMLInputElement} */ (el).disabled) && 1514 + el.offsetParent !== null, 1515 + ); 1516 + 1517 + const currentIndex = focusableElements.indexOf( 1518 + /** @type {HTMLElement} */ (activeElement), 1519 + ); 1520 + if (currentIndex !== -1 && currentIndex < focusableElements.length - 1) { 1521 + focusableElements[currentIndex + 1].focus(); 1522 + } 1523 + 1524 + // Track newly focused input 1525 + setTimeout(() => { 1526 + const newActive = document.activeElement; 1527 + if ( 1528 + newActive && 1529 + (newActive.tagName === "INPUT" || newActive.tagName === "TEXTAREA") 1530 + ) { 1531 + lastFocusedInput = /** @type {HTMLInputElement|HTMLTextAreaElement} */ ( 1532 + newActive 1533 + ); 1534 + } 1535 + }, 50); 1536 + } 1537 + 1538 + /** 1539 + * Handle character input in contentEditable 1540 + * @param {Element} element 1541 + * @param {string} char 1542 + */ 1543 + function insertCharInContentEditable(element, char) { 1544 + const selection = window.getSelection(); 1545 + if (!selection || selection.rangeCount === 0) { 1546 + return; 1547 + } 1548 + 1549 + const range = selection.getRangeAt(0); 1550 + range.deleteContents(); 1551 + const textNode = document.createTextNode(char); 1552 + range.insertNode(textNode); 1553 + range.setStartAfter(textNode); 1554 + range.setEndAfter(textNode); 1555 + selection.removeAllRanges(); 1556 + selection.addRange(range); 1557 + element.dispatchEvent( 1558 + new Event("input", { bubbles: true, cancelable: true }), 1559 + ); 1560 + } 1561 + 1562 + /** 1563 + * Simulate a key press 1564 + * @param {string} keyName - Key to press (e.g., "Enter", "Tab", "a", "A") 1565 + * @returns {{key: string}} 1566 + */ 1567 + function key(keyName) { 1568 + // Get active element, restoring last focused input if needed 1569 + let activeElement = document.activeElement || document.body; 1570 + if (activeElement === document.body && lastFocusedInput) { 1571 + activeElement = lastFocusedInput; 1572 + lastFocusedInput.focus(); 1573 + } 1574 + 1575 + const eventOptions = buildKeyEventOptions(keyName); 1576 + 1577 + // Dispatch keydown 1578 + const keydownPrevented = !activeElement.dispatchEvent( 1579 + new KeyboardEvent("keydown", eventOptions), 1580 + ); 1581 + 1582 + // Handle special keys 1583 + if (!keydownPrevented) { 1584 + if (keyName === "Enter") { 1585 + if (activeElement.tagName === "INPUT" && isTextInput(activeElement)) { 1586 + trySubmitForm(/** @type {HTMLInputElement} */ (activeElement)); 1587 + } else if (activeElement.tagName === "TEXTAREA") { 1588 + insertTextAtCursor( 1589 + /** @type {HTMLTextAreaElement} */ (activeElement), 1590 + "\n", 1591 + ); 1592 + } 1593 + } else if (keyName === "Tab") { 1594 + handleTabKey(activeElement); 1595 + } else if (keyName.length === 1) { 1596 + // Printable character 1597 + if (isTextInput(activeElement)) { 1598 + insertTextAtCursor( 1599 + /** @type {HTMLInputElement|HTMLTextAreaElement} */ (activeElement), 1600 + keyName, 1601 + ); 1602 + activeElement.dispatchEvent( 1603 + new Event("change", { bubbles: true, cancelable: true }), 1604 + ); 1605 + } else if ( 1606 + /** @type {HTMLElement} */ (activeElement).contentEditable === "true" 1607 + ) { 1608 + insertCharInContentEditable(activeElement, keyName); 1609 + } 1610 + } 1611 + } 1612 + 1613 + // Dispatch keypress for printable characters 1614 + if (keyName.length === 1) { 1615 + activeElement.dispatchEvent(new KeyboardEvent("keypress", eventOptions)); 1616 + } 1617 + 1618 + // Dispatch keyup 1619 + activeElement.dispatchEvent(new KeyboardEvent("keyup", eventOptions)); 1620 + 1621 + return { key: keyName }; 1622 + } 1623 + 1624 + /** 1625 + * Get snapshot of the page 1626 + * Returns full snapshot on first call, diff on subsequent calls (to save tokens) 1627 + * @param {SnapOptions & {full?: boolean}} [options] - Filter options, use {full: true} for full snapshot 1628 + * @returns {Snapshot|SnapshotDiff} 1629 + */ 1630 + function snap(options) { 1631 + const snapshot = generateSnapshot(options); 1632 + 1633 + // If full snapshot explicitly requested or options are set (filtering), return full 1634 + if ( 1635 + options?.full || 1636 + (options && Object.keys(options).some((k) => k !== "full")) 1637 + ) { 1638 + if (!options || !Object.keys(options).some((k) => k !== "full")) { 1639 + lastSnapshot = snapshot; 1640 + } 1641 + return snapshot; 1642 + } 1643 + 1644 + // First call or no previous snapshot - return full snapshot 1645 + if (!lastSnapshot) { 1646 + lastSnapshot = snapshot; 1647 + return snapshot; 1648 + } 1649 + 1650 + // Return diff to save tokens 1651 + const result = snapshot.diff(lastSnapshot); 1652 + lastSnapshot = snapshot; 1653 + return result; 1654 + } 1655 + 1656 + /** 1657 + * Get console logs 1658 + * @returns {{logs: ConsoleLog[]}} 1659 + */ 1660 + function logs() { 1661 + return { logs: [...consoleLogs] }; 1662 + } 1663 + 1664 + /** 1665 + * Wait for DOM to stabilize (no mutations for a period) 1666 + * @param {number} [stableMs=300] - How long DOM must be stable 1667 + * @param {number} [timeout=10000] - Maximum wait time 1668 + * @returns {Promise<{waited: string}>} 1669 + */ 1670 + function waitForIdle(stableMs = 300, timeout = 10_000) { 1671 + return new Promise((resolve, reject) => { 1672 + let lastMutationTime = Date.now(); 1673 + let resolved = false; 1674 + const startTime = Date.now(); 1675 + 1676 + const observer = new MutationObserver(() => { 1677 + lastMutationTime = Date.now(); 1678 + }); 1679 + 1680 + observer.observe(document.body, { 1681 + childList: true, 1682 + subtree: true, 1683 + attributes: true, 1684 + characterData: true, 1685 + }); 1686 + 1687 + const checkStable = () => { 1688 + if (resolved) { 1689 + return; 1690 + } 1691 + 1692 + const now = Date.now(); 1693 + if (now - startTime > timeout) { 1694 + resolved = true; 1695 + observer.disconnect(); 1696 + reject(new Error("Timeout waiting for page to stabilize")); 1697 + return; 1698 + } 1699 + 1700 + if (now - lastMutationTime >= stableMs) { 1701 + resolved = true; 1702 + observer.disconnect(); 1703 + resolve({ waited: "idle" }); 1704 + return; 1705 + } 1706 + 1707 + setTimeout(checkStable, 50); 1708 + }; 1709 + 1710 + // Start checking after initial delay 1711 + setTimeout(checkStable, 50); 1712 + }); 1713 + } 1714 + 1715 + /** 1716 + * Wait for condition or timeout 1717 + * @param {number|string} condition - ms to wait, "idle", "text", or "gone" 1718 + * @param {string|number} [value] - Text to wait for, or stableMs for idle 1719 + * @param {number} [timeout=10000] - Maximum wait time in ms 1720 + * @returns {Promise<{waited: number|string}>} 1721 + */ 1722 + async function wait(condition, value, timeout = 10_000) { 1723 + if (typeof condition === "number") { 1724 + // Simple delay 1725 + await new Promise((resolve) => setTimeout(resolve, condition)); 1726 + return { waited: condition }; 1727 + } 1728 + 1729 + if (condition === "idle") { 1730 + // Wait for DOM to stabilize 1731 + const stableMs = typeof value === "number" ? value : 300; 1732 + return waitForIdle(stableMs, timeout); 1733 + } 1734 + 1735 + if (condition === "text" && typeof value === "string") { 1736 + // Wait for text to appear 1737 + const startTime = Date.now(); 1738 + while (Date.now() - startTime < timeout) { 1739 + if (document.body.textContent?.includes(value)) { 1740 + return { waited: `text:${value}` }; 1741 + } 1742 + await new Promise((resolve) => setTimeout(resolve, 100)); 1743 + } 1744 + throw new Error(`Timeout waiting for text: "${value}"`); 1745 + } 1746 + 1747 + if (condition === "gone" && typeof value === "string") { 1748 + // Wait for text to disappear 1749 + const startTime = Date.now(); 1750 + while (Date.now() - startTime < timeout) { 1751 + if (!document.body.textContent?.includes(value)) { 1752 + return { waited: `gone:${value}` }; 1753 + } 1754 + await new Promise((resolve) => setTimeout(resolve, 100)); 1755 + } 1756 + throw new Error(`Timeout waiting for text to disappear: "${value}"`); 1757 + } 1758 + 1759 + throw new Error(`Invalid wait condition: ${condition}`); 1760 + } 1761 + 1762 + // ============================================================================ 1763 + // Browser-level functions (delegate to background script) 1764 + // ============================================================================ 1765 + 1766 + /** @type {number} */ 1767 + let bgMessageCounter = 0; 1768 + 1769 + /** 1770 + * Send a command to the background script and wait for response 1771 + * @param {string} command 1772 + * @param {object} [params={}] 1773 + * @returns {Promise<any>} 1774 + */ 1775 + function sendToBackground(command, params = {}) { 1776 + return new Promise((resolve, reject) => { 1777 + const messageId = `content_${++bgMessageCounter}`; 1778 + 1779 + /** 1780 + * @param {any} response 1781 + */ 1782 + function handleResponse(response) { 1783 + if (response && response.bgMessageId === messageId) { 1784 + browser.runtime.onMessage.removeListener(handleResponse); 1785 + if (response.error) { 1786 + reject(new Error(response.error)); 1787 + } else { 1788 + resolve(response.result); 1789 + } 1790 + } 1791 + } 1792 + 1793 + browser.runtime.onMessage.addListener(handleResponse); 1794 + browser.runtime.sendMessage({ command, params, bgMessageId: messageId }); 1795 + 1796 + // Timeout after 30 seconds 1797 + setTimeout(() => { 1798 + browser.runtime.onMessage.removeListener(handleResponse); 1799 + reject(new Error("Background script timeout")); 1800 + }, 30_000); 1801 + }); 1802 + } 1803 + 1804 + /** 1805 + * Take a screenshot 1806 + * @param {string} [path] - Output file path 1807 + * @returns {Promise<string>} Path to saved screenshot 1808 + */ 1809 + async function shot(path) { 1810 + const result = await sendToBackground("screenshot", { output_path: path }); 1811 + return result.screenshot_path || result.screenshot; 1812 + } 1813 + 1814 + /** 1815 + * Create a new tab 1816 + * @param {string} [url] - URL to open 1817 + * @returns {Promise<{tabId: string, url: string}>} Tab info 1818 + */ 1819 + async function tab(url) { 1820 + const result = await sendToBackground("new-tab", { url }); 1821 + return { tabId: result.tabId, url: result.url }; 1822 + } 1823 + 1824 + /** 1825 + * List all managed tabs 1826 + * @returns {Promise<Array<{id: string, url: string, title: string, active: boolean}>>} 1827 + */ 1828 + async function tabs() { 1829 + const result = await sendToBackground("list-tabs"); 1830 + return result.tabs; 1831 + } 1832 + 1833 + /** 1834 + * Download a file 1835 + * @param {string} url - URL to download 1836 + * @param {string} [filename] - Filename (relative to downloads folder) 1837 + * @returns {Promise<{downloaded: string, path: string}>} 1838 + */ 1839 + async function download(url, filename) { 1840 + return sendToBackground("download", { url, filename }); 1841 + } 1842 + 1843 + // ============================================================================ 1844 + // Message handling for background script commands 1845 + // ============================================================================ 1846 + 1847 + /** 1848 + * Serialize result for transport 1849 + * @param {any} value 1850 + * @returns {any} 1851 + */ 1852 + function serializeResult(value) { 1853 + if (value === null || value === undefined) { 1854 + return value; 1855 + } 1856 + 1857 + // Handle Snapshot and SnapshotElement 1858 + if (value instanceof Snapshot || value instanceof SnapshotElement) { 1859 + return value.toJSON(); 1860 + } 1861 + 1862 + // Handle arrays 1863 + if (Array.isArray(value)) { 1864 + return value.map((v) => serializeResult(v)); 1865 + } 1866 + 1867 + // Handle objects with toJSON 1868 + if (typeof value === "object" && typeof value.toJSON === "function") { 1869 + return value.toJSON(); 1870 + } 1871 + 1872 + // Try structuredClone for other objects 1873 + try { 1874 + return structuredClone(value); 1875 + } catch { 1876 + return String(value); 1877 + } 1878 + } 1879 + 1880 + /** 1881 + * Handle exec command - execute JavaScript with API available 1882 + * @param {string} code - JavaScript code to execute 1883 + * @returns {Promise<{result: any}>} 1884 + */ 1885 + async function handleExec(code) { 1886 + // Create async function to allow await in code 1887 + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; 1888 + 1889 + // Wrap code to auto-return the last expression if no explicit return 1890 + let wrappedCode = code.trim(); 1891 + if (!wrappedCode.includes("return ") && !wrappedCode.includes("return;")) { 1892 + // Find the last statement and return it 1893 + const lines = wrappedCode.split("\n"); 1894 + const lastLine = lines.pop()?.trim() || ""; 1895 + if (lastLine && !lastLine.endsWith(";")) { 1896 + // Last line is an expression - return it 1897 + wrappedCode = [...lines, `return (${lastLine})`].join("\n"); 1898 + } else if (lastLine) { 1899 + // Last line ends with semicolon - try to return it anyway 1900 + const expr = lastLine.slice(0, -1).trim(); 1901 + if ( 1902 + expr && 1903 + !expr.startsWith("const ") && 1904 + !expr.startsWith("let ") && 1905 + !expr.startsWith("var ") 1906 + ) { 1907 + wrappedCode = [...lines, `return (${expr})`].join("\n"); 1908 + } 1909 + } 1910 + } 1911 + 1912 + // Make API functions available in the execution context 1913 + const fn = new AsyncFunction( 1914 + // Content script functions 1915 + "click", 1916 + "type", 1917 + "hover", 1918 + "drag", 1919 + "select", 1920 + "key", 1921 + "snap", 1922 + "logs", 1923 + "find", 1924 + "wait", 1925 + // Background script functions 1926 + "shot", 1927 + "tab", 1928 + "tabs", 1929 + "download", 1930 + wrappedCode, 1931 + ); 1932 + 1933 + const result = await fn( 1934 + // Content script functions 1935 + click, 1936 + type, 1937 + hover, 1938 + drag, 1939 + select, 1940 + key, 1941 + snap, 1942 + logs, 1943 + find, 1944 + wait, 1945 + // Background script functions 1946 + shot, 1947 + tab, 1948 + tabs, 1949 + download, 1950 + ); 1951 + 1952 + return { result: serializeResult(result) }; 1953 + } 1954 + 1955 + browser.runtime.onMessage.addListener((message, _sender, _sendResponse) => { 1956 + const { command, params, messageId } = message; 1957 + 1958 + // Skip if this is a response to a background script request 1959 + if (message.bgMessageId) { 1960 + return false; 1961 + } 1962 + 1963 + if (!virtualCursor && command !== "exec") { 1964 + initializeVirtualCursor(); 1965 + } 1966 + 1967 + /** */ 1968 + async function executeCommand() { 1969 + try { 1970 + let result; 1971 + 1972 + switch (command) { 1973 + case "exec": { 1974 + result = await handleExec(params.code); 1975 + break; 1976 + } 1977 + default: { 1978 + throw new Error(`Unknown command: ${command}`); 1979 + } 1980 + } 1981 + 1982 + browser.runtime.sendMessage({ messageId, result, success: true }); 1983 + } catch (error) { 1984 + browser.runtime.sendMessage({ 1985 + messageId, 1986 + error: error instanceof Error ? error.message : String(error), 1987 + success: false, 1988 + }); 1989 + } 1990 + } 1991 + 1992 + executeCommand(); 1993 + return true; 1994 + });
+40
skills/browser-cli/extension/manifest.json
··· 1 + { 2 + "manifest_version": 2, 3 + "name": "Browser CLI Controller", 4 + "version": "1.0.25", 5 + "description": "Control your browser from the command line", 6 + 7 + "applications": { 8 + "gecko": { 9 + "id": "browser-cli-controller@thalheim.io", 10 + "strict_min_version": "57.0" 11 + } 12 + }, 13 + 14 + "permissions": [ 15 + "tabs", 16 + "activeTab", 17 + "webNavigation", 18 + "nativeMessaging", 19 + "<all_urls>", 20 + "downloads", 21 + "storage", 22 + "contextMenus" 23 + ], 24 + 25 + "background": { 26 + "scripts": ["background.js"], 27 + "persistent": true 28 + }, 29 + 30 + "browser_action": { 31 + "default_title": "Browser CLI Controller", 32 + "default_icon": { 33 + "48": "icon.png" 34 + } 35 + }, 36 + 37 + "icons": { 38 + "48": "icon.png" 39 + } 40 + }