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