claude-comms 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,736 @@
1
+ #!/usr/bin/env -S uv run --quiet --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = [
5
+ # "mcp>=1.1.2",
6
+ # ]
7
+ # ///
8
+ """
9
+ Chrome DevTools MCP wrapper - persistent daemon with Unix socket bridge.
10
+
11
+ Architecture:
12
+ - Daemon spawns chrome-devtools-mcp once, keeps it running
13
+ - Daemon listens on Unix socket for client commands
14
+ - Multiple CLI invocations connect via socket, share same MCP session
15
+ - State (snapshots, tabs, etc.) persists across all commands
16
+ """
17
+
18
+ import asyncio
19
+ import argparse
20
+ import json
21
+ import os
22
+ import signal
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any, Dict, Optional
26
+
27
+ # State directory
28
+ STATE_DIR = Path.home() / ".browsertools"
29
+ SOCKET_PATH = STATE_DIR / "daemon.sock"
30
+ PID_FILE = STATE_DIR / "daemon.pid"
31
+ CONFIG_FILE = STATE_DIR / "config.json"
32
+
33
+ # Global daemon state
34
+ mcp_proc = None
35
+ mcp_reader = None
36
+ mcp_writer = None
37
+ pending_requests = {} # msg_id -> response_future
38
+
39
+
40
+ def ensure_state_dir():
41
+ """Create state directory."""
42
+ STATE_DIR.mkdir(exist_ok=True)
43
+
44
+
45
+ def get_daemon_pid() -> Optional[int]:
46
+ """Get PID of running daemon."""
47
+ if not PID_FILE.exists():
48
+ return None
49
+ try:
50
+ pid = int(PID_FILE.read_text().strip())
51
+ os.kill(pid, 0) # Check if alive
52
+ return pid
53
+ except (ProcessLookupError, ValueError):
54
+ PID_FILE.unlink(missing_ok=True)
55
+ return None
56
+
57
+
58
+ def is_daemon_running() -> bool:
59
+ """Check if daemon is running."""
60
+ return get_daemon_pid() is not None and SOCKET_PATH.exists()
61
+
62
+
63
+ def load_config() -> Dict[str, Any]:
64
+ """Load config file or return defaults."""
65
+ # Try user config first
66
+ if CONFIG_FILE.exists():
67
+ try:
68
+ with open(CONFIG_FILE) as f:
69
+ return json.load(f)
70
+ except Exception as e:
71
+ print(f"Warning: Failed to load user config: {e}", file=sys.stderr)
72
+
73
+ # Try repo default config
74
+ repo_config = Path(__file__).parent / "config.json"
75
+ if repo_config.exists():
76
+ try:
77
+ with open(repo_config) as f:
78
+ return json.load(f)
79
+ except Exception as e:
80
+ print(f"Warning: Failed to load repo config: {e}", file=sys.stderr)
81
+
82
+ # Fallback to hardcoded defaults
83
+ return {
84
+ "mcp_command": "npx",
85
+ "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--headless=true"]
86
+ }
87
+
88
+
89
+ def save_default_config():
90
+ """Save default config file."""
91
+ ensure_state_dir()
92
+ config = {
93
+ "mcp_command": "npx",
94
+ "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated"],
95
+ "_comment": "Customize MCP server startup. Examples:",
96
+ "_example_headless": {
97
+ "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--headless=true"]
98
+ },
99
+ "_example_custom_chrome": {
100
+ "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--executablePath=/usr/local/bin/chromium-mcp"]
101
+ },
102
+ "_example_sandbox": {
103
+ "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated=true", "--headless=true", "--executablePath=/usr/local/bin/chromium-mcp"]
104
+ }
105
+ }
106
+ with open(CONFIG_FILE, "w") as f:
107
+ json.dump(config, f, indent=2)
108
+ print(f"Created config file: {CONFIG_FILE}", file=sys.stderr)
109
+
110
+
111
+ # ============================================================================
112
+ # DAEMON MODE - Bridge between Unix socket and MCP stdio
113
+ # ============================================================================
114
+
115
+ async def read_mcp_responses():
116
+ """Read responses from MCP stdout and route to clients."""
117
+ global mcp_reader, pending_requests
118
+
119
+ while True:
120
+ try:
121
+ # Read line with increased limit for large snapshots
122
+ line = await mcp_reader.readline()
123
+ if not line:
124
+ print("MCP server closed stdout", file=sys.stderr)
125
+ break
126
+
127
+ try:
128
+ resp = json.loads(line.decode())
129
+ msg_id = resp.get("id")
130
+
131
+ # Route response to waiting client
132
+ if msg_id and msg_id in pending_requests:
133
+ future = pending_requests.pop(msg_id)
134
+ future.set_result(resp)
135
+ except json.JSONDecodeError as e:
136
+ print(f"Invalid JSON from MCP: {e}", file=sys.stderr)
137
+ print(f"Line length: {len(line)}", file=sys.stderr)
138
+
139
+ except Exception as e:
140
+ print(f"Error reading MCP: {e}", file=sys.stderr)
141
+ # Don't break on limit errors, just log and continue
142
+ if "longer than limit" not in str(e):
143
+ break
144
+
145
+
146
+ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
147
+ """Handle a client connection."""
148
+ global mcp_writer, pending_requests
149
+
150
+ try:
151
+ while True:
152
+ # Read request from client
153
+ line = await reader.readline()
154
+ if not line:
155
+ break
156
+
157
+ try:
158
+ req = json.loads(line.decode())
159
+ msg_id = req.get("id")
160
+
161
+ # Create future for response
162
+ response_future = asyncio.Future()
163
+ pending_requests[msg_id] = response_future
164
+
165
+ # Forward to MCP server
166
+ mcp_writer.write(line)
167
+ await mcp_writer.drain()
168
+
169
+ # Wait for response
170
+ try:
171
+ resp = await asyncio.wait_for(response_future, timeout=30.0)
172
+ writer.write(json.dumps(resp).encode() + b'\n')
173
+ await writer.drain()
174
+ except asyncio.TimeoutError:
175
+ writer.write(json.dumps({
176
+ "jsonrpc": "2.0",
177
+ "id": msg_id,
178
+ "error": {"code": -1, "message": "Timeout waiting for MCP response"}
179
+ }).encode() + b'\n')
180
+ await writer.drain()
181
+ pending_requests.pop(msg_id, None)
182
+
183
+ except json.JSONDecodeError:
184
+ print(f"Invalid JSON from client: {line}", file=sys.stderr)
185
+
186
+ except Exception as e:
187
+ print(f"Client handler error: {e}", file=sys.stderr)
188
+ finally:
189
+ writer.close()
190
+
191
+
192
+ async def shutdown_mcp():
193
+ """Properly shutdown MCP server and all Chrome child processes."""
194
+ global mcp_proc
195
+
196
+ if not mcp_proc:
197
+ return
198
+
199
+ print("Shutting down MCP server and Chrome...", file=sys.stderr)
200
+
201
+ try:
202
+ # Get process group ID
203
+ pgid = os.getpgid(mcp_proc.pid)
204
+
205
+ # Send SIGTERM to entire process group (kills Chrome and all children)
206
+ os.killpg(pgid, signal.SIGTERM)
207
+
208
+ # Wait for graceful shutdown
209
+ try:
210
+ await asyncio.wait_for(mcp_proc.wait(), timeout=5.0)
211
+ print("MCP server stopped gracefully", file=sys.stderr)
212
+ except asyncio.TimeoutError:
213
+ # Force kill if still running
214
+ print("Force killing MCP server...", file=sys.stderr)
215
+ os.killpg(pgid, signal.SIGKILL)
216
+ await mcp_proc.wait()
217
+
218
+ except ProcessLookupError:
219
+ # Already dead
220
+ pass
221
+ except Exception as e:
222
+ print(f"Error during shutdown: {e}", file=sys.stderr)
223
+
224
+
225
+ async def run_daemon():
226
+ """Run the persistent daemon."""
227
+ global mcp_proc, mcp_reader, mcp_writer
228
+
229
+ ensure_state_dir()
230
+
231
+ # Clean up old socket if exists
232
+ if SOCKET_PATH.exists():
233
+ SOCKET_PATH.unlink()
234
+
235
+ # Load config
236
+ config = load_config()
237
+ mcp_command = config.get("mcp_command", "npx")
238
+ mcp_args = config.get("mcp_args", ["-y", "chrome-devtools-mcp@latest", "--isolated"])
239
+
240
+ print(f"Starting chrome-devtools-mcp...", file=sys.stderr)
241
+ print(f"Command: {mcp_command} {' '.join(mcp_args)}", file=sys.stderr)
242
+
243
+ # Start MCP server as subprocess in new process group
244
+ # This ensures we can kill Chrome and all its children together
245
+ mcp_proc = await asyncio.create_subprocess_exec(
246
+ mcp_command, *mcp_args,
247
+ stdin=asyncio.subprocess.PIPE,
248
+ stdout=asyncio.subprocess.PIPE,
249
+ stderr=sys.stderr,
250
+ start_new_session=True, # Creates new process group
251
+ limit=1024 * 1024 * 10, # 10MB limit for large snapshots
252
+ )
253
+
254
+ mcp_reader = mcp_proc.stdout
255
+ mcp_writer = mcp_proc.stdin
256
+
257
+ # Write PID file
258
+ PID_FILE.write_text(str(os.getpid()))
259
+
260
+ print(f"Starting Unix socket server at {SOCKET_PATH}...", file=sys.stderr)
261
+
262
+ # Start Unix socket server
263
+ server = await asyncio.start_unix_server(
264
+ handle_client,
265
+ path=str(SOCKET_PATH)
266
+ )
267
+
268
+ print(f"Daemon ready. PID: {os.getpid()}", file=sys.stderr)
269
+
270
+ # Setup signal handlers for graceful shutdown
271
+ loop = asyncio.get_event_loop()
272
+
273
+ async def handle_shutdown(sig):
274
+ print(f"\nReceived {sig.name}, shutting down...", file=sys.stderr)
275
+ await shutdown_mcp()
276
+ server.close()
277
+ await server.wait_closed()
278
+ SOCKET_PATH.unlink(missing_ok=True)
279
+ PID_FILE.unlink(missing_ok=True)
280
+ loop.stop()
281
+
282
+ for sig in (signal.SIGTERM, signal.SIGINT):
283
+ loop.add_signal_handler(
284
+ sig,
285
+ lambda s=sig: asyncio.create_task(handle_shutdown(s))
286
+ )
287
+
288
+ # Run both the socket server and MCP response reader
289
+ try:
290
+ await asyncio.gather(
291
+ server.serve_forever(),
292
+ read_mcp_responses(),
293
+ )
294
+ except KeyboardInterrupt:
295
+ print("\nShutting down daemon...", file=sys.stderr)
296
+ finally:
297
+ # Cleanup
298
+ await shutdown_mcp()
299
+ server.close()
300
+ await server.wait_closed()
301
+ SOCKET_PATH.unlink(missing_ok=True)
302
+ PID_FILE.unlink(missing_ok=True)
303
+
304
+
305
+ # ============================================================================
306
+ # CLIENT MODE - Connect to daemon and send commands
307
+ # ============================================================================
308
+
309
+ async def send_command(tool_name: str, args: Dict[str, Any]) -> Any:
310
+ """Send a command to the daemon via socket."""
311
+ if not is_daemon_running():
312
+ raise RuntimeError("Daemon not running. Start with: bt daemon start")
313
+
314
+ # Connect to daemon's Unix socket with increased limit for large responses
315
+ reader, writer = await asyncio.open_unix_connection(
316
+ str(SOCKET_PATH),
317
+ limit=1024 * 1024 * 10 # 10MB limit for large snapshots
318
+ )
319
+
320
+ try:
321
+ # Create JSON-RPC request
322
+ msg_id = os.urandom(8).hex()
323
+ request = {
324
+ "jsonrpc": "2.0",
325
+ "method": "tools/call",
326
+ "params": {
327
+ "name": tool_name,
328
+ "arguments": args
329
+ },
330
+ "id": msg_id
331
+ }
332
+
333
+ # Send request
334
+ writer.write(json.dumps(request).encode() + b'\n')
335
+ await writer.drain()
336
+
337
+ # Read response
338
+ line = await reader.readline()
339
+ response = json.loads(line.decode())
340
+
341
+ if "error" in response:
342
+ raise RuntimeError(f"MCP error: {response['error']}")
343
+
344
+ return response.get("result")
345
+
346
+ finally:
347
+ writer.close()
348
+ await writer.wait_closed()
349
+
350
+
351
+ def clean_output(result: Any) -> str:
352
+ """Extract clean text from MCP result."""
353
+ if isinstance(result, dict):
354
+ if "content" in result:
355
+ content = result["content"]
356
+ if isinstance(content, list):
357
+ texts = []
358
+ for item in content:
359
+ if isinstance(item, dict):
360
+ if "text" in item:
361
+ texts.append(item["text"])
362
+ elif item.get("type") == "image" and "data" in item:
363
+ # Return base64 image data
364
+ mime = item.get("mimeType", "image/png")
365
+ b64_data = item["data"]
366
+ texts.append(f"\n## Base64 Image ({mime})")
367
+ texts.append(b64_data)
368
+
369
+ return "\n".join(texts) if texts else str(content)
370
+ elif "text" in result:
371
+ return result["text"]
372
+ return str(result)
373
+
374
+
375
+ async def execute_command(cmd: str, cmd_args: Dict[str, Any]):
376
+ """Execute a single command via daemon."""
377
+
378
+ # Map commands to MCP tools
379
+ tool_name = None
380
+ tool_args = {}
381
+
382
+ if cmd == "nav":
383
+ tool_name = "navigate_page"
384
+ tool_args = {"url": cmd_args["url"]}
385
+ if "timeout" in cmd_args:
386
+ tool_args["timeout"] = cmd_args["timeout"]
387
+ elif cmd == "snap":
388
+ tool_name = "take_snapshot"
389
+ elif cmd == "click":
390
+ tool_name = "click"
391
+ tool_args = {"uid": cmd_args["uid"]}
392
+ elif cmd == "fill":
393
+ tool_name = "fill"
394
+ tool_args = {"uid": cmd_args["uid"], "value": cmd_args["value"]}
395
+ elif cmd == "shot":
396
+ tool_name = "take_screenshot"
397
+ if "path" in cmd_args:
398
+ tool_args["filePath"] = cmd_args["path"]
399
+ elif cmd == "wait":
400
+ tool_name = "wait_for"
401
+ tool_args = {"text": cmd_args["text"]}
402
+ if "timeout" in cmd_args:
403
+ tool_args["timeout"] = cmd_args["timeout"]
404
+ elif cmd == "eval":
405
+ tool_name = "evaluate_script"
406
+ tool_args = {"function": cmd_args["function"]}
407
+ if "args" in cmd_args:
408
+ tool_args["args"] = cmd_args["args"]
409
+ elif cmd == "key":
410
+ tool_name = "press_key"
411
+ tool_args = {"key": cmd_args["key"]}
412
+ elif cmd == "hover":
413
+ tool_name = "hover"
414
+ tool_args = {"uid": cmd_args["uid"]}
415
+ elif cmd == "netlist":
416
+ tool_name = "list_network_requests"
417
+ if "resource_types" in cmd_args:
418
+ tool_args["resourceTypes"] = cmd_args["resource_types"]
419
+ if "page_size" in cmd_args:
420
+ tool_args["pageSize"] = cmd_args["page_size"]
421
+ elif cmd == "netget":
422
+ tool_name = "get_network_request"
423
+ if "reqid" in cmd_args:
424
+ tool_args["reqid"] = cmd_args["reqid"]
425
+ elif cmd == "conslist":
426
+ tool_name = "list_console_messages"
427
+ if "types" in cmd_args:
428
+ tool_args["types"] = cmd_args["types"]
429
+ if "page_size" in cmd_args:
430
+ tool_args["pageSize"] = cmd_args["page_size"]
431
+ elif cmd == "consget":
432
+ tool_name = "get_console_message"
433
+ tool_args = {"msgid": cmd_args["msgid"]}
434
+ elif cmd == "resize":
435
+ tool_name = "resize_page"
436
+ tool_args = {"width": cmd_args["width"], "height": cmd_args["height"]}
437
+ elif cmd == "dialog":
438
+ tool_name = "handle_dialog"
439
+ tool_args = {"action": cmd_args["action"]}
440
+ if "prompt_text" in cmd_args:
441
+ tool_args["promptText"] = cmd_args["prompt_text"]
442
+ elif cmd == "upload":
443
+ tool_name = "upload_file"
444
+ tool_args = {"uid": cmd_args["uid"], "filePath": cmd_args["file_path"]}
445
+ elif cmd == "drag":
446
+ tool_name = "drag"
447
+ tool_args = {"from_uid": cmd_args["from_uid"], "to_uid": cmd_args["to_uid"]}
448
+ elif cmd == "fillform":
449
+ tool_name = "fill_form"
450
+ tool_args = {"elements": cmd_args["elements"]}
451
+ else:
452
+ raise ValueError(f"Unknown command: {cmd}")
453
+
454
+ # Send to daemon
455
+ result = await send_command(tool_name, tool_args)
456
+ return clean_output(result)
457
+
458
+
459
+ # ============================================================================
460
+ # CLI INTERFACE
461
+ # ============================================================================
462
+
463
+ async def main():
464
+ parser = argparse.ArgumentParser(
465
+ prog="browsertools.py",
466
+ description="Chrome DevTools MCP wrapper - persistent daemon mode",
467
+ formatter_class=argparse.RawDescriptionHelpFormatter,
468
+ epilog=r"""
469
+ browsertools.py - Chrome DevTools automation tool
470
+
471
+
472
+ Usage: uv run .agents/tools/chrome-devtools/browsertools.py <command>
473
+
474
+ DAEMON MANAGEMENT:
475
+ daemon start Start persistent daemon + Chrome
476
+ IMPORTANT: Run in background with &
477
+ Example: uv run .agents/tools/chrome-devtools/browsertools.py daemon start &
478
+ daemon stop Stop daemon, kill Chrome cleanly
479
+ daemon status Check if daemon running
480
+ daemon config Create config file
481
+
482
+ NAVIGATION:
483
+ nav <url> Navigate to URL
484
+ --timeout <ms> Max wait time in milliseconds
485
+ wait <text> Wait for text to appear
486
+ --timeout <ms> Max wait time in milliseconds
487
+
488
+ INSPECTION:
489
+ snap Take a11y tree snapshot (get element UIDs)
490
+ IMPORTANT: Always use latest snapshot for UIDs
491
+ shot [path] Screenshot viewport (optional: save to path)
492
+ eval <js> Execute JavaScript, returns JSON
493
+ --args <json> JSON array of element references (advanced)
494
+ Simple: "() => document.title"
495
+ Complex: "(el) => el.innerText" --args '[{"uid":"1_23"}]'
496
+
497
+ INTERACTION:
498
+ click <uid> Click element by UID from snapshot
499
+ fill <uid> <value> Fill input/textarea or select option
500
+ key <key> Press key or combo
501
+ Examples: "Enter", "Tab", "Control+A"
502
+ hover <uid> Hover over element
503
+ drag <from> <to> Drag element from UID to target UID
504
+ fillform <json> Fill multiple form fields at once
505
+ Example: '[{"uid":"1_1","value":"text"}]'
506
+
507
+ DEBUGGING:
508
+ conslist List console messages
509
+ --types error warn Filter by type
510
+ --size N Max messages
511
+
512
+ consget <msgid> Get console message details
513
+
514
+ netlist List network requests
515
+ --types xhr fetch Filter by resource type
516
+ --size N Max requests
517
+
518
+ netget [reqid] Get request details (omit reqid for currently selected)
519
+
520
+ PAGE MANIPULATION:
521
+ resize <width> <height> Resize viewport
522
+ dialog <accept|dismiss> Handle alert/confirm/prompt
523
+ --text <text> Text for prompt
524
+ upload <uid> <file_path> Upload file
525
+
526
+ USAGE TIPS:
527
+ **Snapshots** are verbose. Always try to pipe grep first
528
+ - Default pattern: snap | grep -iC5 "search term"
529
+ - **Interaction commands** return updated page state snapshots: click, fill, key, hover, drag, wait return updated page state
530
+ - Pipe to grep with context. eg. click 1_7 | grep -iC5 "dashboard\|home"
531
+ - No grep hits? Run explicit snap to see full page
532
+
533
+ Limit output for **debugging** commands
534
+ - Network: netlist --size 10 --types fetch
535
+ - Console: conslist --size 5 --types error
536
+
537
+ EXAMPLES:
538
+ # Login workflow
539
+ uv run .agents/tools/chrome-devtools/browsertools.py daemon start &
540
+ sleep 5 # Wait for daemon to be ready
541
+ uv run .agents/tools/chrome-devtools/browsertools.py nav http://app.com/login
542
+ uv run .agents/tools/chrome-devtools/browsertools.py snap | grep -iC5 "email\|username\|login"
543
+ uv run .agents/tools/chrome-devtools/browsertools.py fill 1_23 user@example.com
544
+ uv run .agents/tools/chrome-devtools/browsertools.py snap | grep -iC5 "button.*(submit|login|sign.?in)"
545
+ uv run .agents/tools/chrome-devtools/browsertools.py click 1_25 | grep -iC5 "dashboard\|home\|welcome"
546
+ uv run .agents/tools/chrome-devtools/browsertools.py daemon stop
547
+
548
+ IMPORTANT:
549
+ - UIDs only valid from latest snapshot (interaction commands return fresh ones)
550
+ - Daemon maintains state (snapshots, network, console) across all commands
551
+ - Always stop daemon when you are finished to clean up browser resources
552
+ """
553
+ )
554
+
555
+ subparsers = parser.add_subparsers(dest="cmd", required=True)
556
+
557
+ # Daemon management
558
+ daemon = subparsers.add_parser("daemon", help="Manage daemon")
559
+ daemon_sub = daemon.add_subparsers(dest="daemon_cmd", required=True)
560
+ daemon_sub.add_parser("start", help="Start daemon")
561
+ daemon_sub.add_parser("stop", help="Stop daemon")
562
+ daemon_sub.add_parser("status", help="Check daemon status")
563
+ daemon_sub.add_parser("config", help="Create default config file")
564
+
565
+ # Browser commands
566
+ nav = subparsers.add_parser("nav", help="Navigate to URL")
567
+ nav.add_argument("url", help="URL to navigate to")
568
+ nav.add_argument("--timeout", type=int, help="Max wait time in milliseconds")
569
+
570
+ subparsers.add_parser("snap", help="Take page snapshot")
571
+
572
+ shot = subparsers.add_parser("shot", help="Take screenshot")
573
+ shot.add_argument("path", nargs="?", help="Save path (optional)")
574
+
575
+ click = subparsers.add_parser("click", help="Click element")
576
+ click.add_argument("uid", help="Element UID from snapshot")
577
+
578
+ fill = subparsers.add_parser("fill", help="Fill input")
579
+ fill.add_argument("uid", help="Element UID")
580
+ fill.add_argument("value", help="Value to fill")
581
+
582
+ wait = subparsers.add_parser("wait", help="Wait for text")
583
+ wait.add_argument("text", help="Text to wait for")
584
+ wait.add_argument("--timeout", type=int, help="Max wait time in milliseconds")
585
+
586
+ eval_parser = subparsers.add_parser("eval", help="Execute JavaScript")
587
+ eval_parser.add_argument("function", help="JS function to execute")
588
+ eval_parser.add_argument("--args", help="JSON array of element UIDs to pass as arguments")
589
+
590
+ # Keyboard & mouse
591
+ key = subparsers.add_parser("key", help="Press keyboard key")
592
+ key.add_argument("key", help="Key to press (e.g., 'Enter', 'Tab', 'Escape')")
593
+
594
+ hover_parser = subparsers.add_parser("hover", help="Hover over element")
595
+ hover_parser.add_argument("uid", help="Element UID from snapshot")
596
+
597
+ # Network debugging
598
+ netlist = subparsers.add_parser("netlist", help="List network requests")
599
+ netlist.add_argument("--types", nargs="+", help="Filter by resource types")
600
+ netlist.add_argument("--size", type=int, help="Max requests to return")
601
+
602
+ netget = subparsers.add_parser("netget", help="Get network request details")
603
+ netget.add_argument("reqid", nargs="?", type=int, help="Request ID (omit for latest)")
604
+
605
+ # Console debugging
606
+ conslist = subparsers.add_parser("conslist", help="List console messages")
607
+ conslist.add_argument("--types", nargs="+", help="Filter by types (log, error, warn)")
608
+ conslist.add_argument("--size", type=int, help="Max messages to return")
609
+
610
+ consget = subparsers.add_parser("consget", help="Get console message details")
611
+ consget.add_argument("msgid", type=int, help="Message ID")
612
+
613
+ # Page manipulation
614
+ resize = subparsers.add_parser("resize", help="Resize page viewport")
615
+ resize.add_argument("width", type=int, help="Width in pixels")
616
+ resize.add_argument("height", type=int, help="Height in pixels")
617
+
618
+ dialog = subparsers.add_parser("dialog", help="Handle browser dialog (alert/confirm/prompt)")
619
+ dialog.add_argument("action", choices=["accept", "dismiss"], help="Accept or dismiss dialog")
620
+ dialog.add_argument("--text", dest="prompt_text", help="Text for prompt dialog")
621
+
622
+ upload = subparsers.add_parser("upload", help="Upload file through element")
623
+ upload.add_argument("uid", help="File input element UID")
624
+ upload.add_argument("file_path", help="Local file path to upload")
625
+
626
+ # Advanced interaction
627
+ drag_parser = subparsers.add_parser("drag", help="Drag element to target")
628
+ drag_parser.add_argument("from_uid", help="Source element UID")
629
+ drag_parser.add_argument("to_uid", help="Target element UID")
630
+
631
+ fillform_parser = subparsers.add_parser("fillform", help="Fill multiple form fields")
632
+ fillform_parser.add_argument("elements", help="JSON array: [{'uid':'1_1','value':'text'}]")
633
+
634
+ args = parser.parse_args()
635
+
636
+ # Handle daemon commands
637
+ if args.cmd == "daemon":
638
+ if args.daemon_cmd == "start":
639
+ if is_daemon_running():
640
+ print("Daemon already running", file=sys.stderr)
641
+ sys.exit(1)
642
+ await run_daemon()
643
+
644
+ elif args.daemon_cmd == "stop":
645
+ pid = get_daemon_pid()
646
+ if not pid:
647
+ print("Daemon not running", file=sys.stderr)
648
+ sys.exit(1)
649
+ os.kill(pid, signal.SIGTERM)
650
+ print(f"Stopped daemon (PID {pid})")
651
+
652
+ elif args.daemon_cmd == "status":
653
+ if is_daemon_running():
654
+ print(f"Daemon running (PID {get_daemon_pid()})")
655
+ print(f"Socket: {SOCKET_PATH}")
656
+ else:
657
+ print("Daemon not running")
658
+
659
+ elif args.daemon_cmd == "config":
660
+ save_default_config()
661
+ print(f"\nEdit {CONFIG_FILE} to customize MCP server settings.")
662
+ print("\nExamples:")
663
+ print(" Headless mode: --headless=true")
664
+ print(" Custom Chrome: --executablePath=/path/to/chrome")
665
+ print(" Sandbox: --headless=true --executablePath=/usr/local/bin/chromium-mcp")
666
+
667
+ return
668
+
669
+ # Execute browser command
670
+ try:
671
+ cmd_args_dict = {}
672
+
673
+ if args.cmd == "nav":
674
+ cmd_args_dict["url"] = args.url
675
+ if args.timeout:
676
+ cmd_args_dict["timeout"] = args.timeout
677
+ elif args.cmd == "click":
678
+ cmd_args_dict["uid"] = args.uid
679
+ elif args.cmd == "fill":
680
+ cmd_args_dict["uid"] = args.uid
681
+ cmd_args_dict["value"] = args.value
682
+ elif args.cmd == "shot" and args.path:
683
+ cmd_args_dict["path"] = args.path
684
+ elif args.cmd == "wait":
685
+ cmd_args_dict["text"] = args.text
686
+ if args.timeout:
687
+ cmd_args_dict["timeout"] = args.timeout
688
+ elif args.cmd == "eval":
689
+ cmd_args_dict["function"] = args.function
690
+ if args.args:
691
+ cmd_args_dict["args"] = json.loads(args.args)
692
+ elif args.cmd == "key":
693
+ cmd_args_dict["key"] = args.key
694
+ elif args.cmd == "hover":
695
+ cmd_args_dict["uid"] = args.uid
696
+ elif args.cmd == "netlist":
697
+ if args.types:
698
+ cmd_args_dict["resource_types"] = args.types
699
+ if args.size:
700
+ cmd_args_dict["page_size"] = args.size
701
+ elif args.cmd == "netget":
702
+ if args.reqid:
703
+ cmd_args_dict["reqid"] = args.reqid
704
+ elif args.cmd == "conslist":
705
+ if args.types:
706
+ cmd_args_dict["types"] = args.types
707
+ if args.size:
708
+ cmd_args_dict["page_size"] = args.size
709
+ elif args.cmd == "consget":
710
+ cmd_args_dict["msgid"] = args.msgid
711
+ elif args.cmd == "resize":
712
+ cmd_args_dict["width"] = args.width
713
+ cmd_args_dict["height"] = args.height
714
+ elif args.cmd == "dialog":
715
+ cmd_args_dict["action"] = args.action
716
+ if args.prompt_text:
717
+ cmd_args_dict["prompt_text"] = args.prompt_text
718
+ elif args.cmd == "upload":
719
+ cmd_args_dict["uid"] = args.uid
720
+ cmd_args_dict["file_path"] = args.file_path
721
+ elif args.cmd == "drag":
722
+ cmd_args_dict["from_uid"] = args.from_uid
723
+ cmd_args_dict["to_uid"] = args.to_uid
724
+ elif args.cmd == "fillform":
725
+ cmd_args_dict["elements"] = json.loads(args.elements)
726
+
727
+ output = await execute_command(args.cmd, cmd_args_dict)
728
+ print(output)
729
+
730
+ except Exception as e:
731
+ print(f"Error: {e}", file=sys.stderr)
732
+ sys.exit(1)
733
+
734
+
735
+ if __name__ == "__main__":
736
+ asyncio.run(main())