claude-comms 1.0.46 → 2.0.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -13
  3. package/package.json +6 -10
  4. package/.agents/AGENTS.md +0 -61
  5. package/.agents/repo.md +0 -18
  6. package/.agents/tools/README.md +0 -45
  7. package/.agents/tools/agent-job/README.md +0 -208
  8. package/.agents/tools/agent-job/__pycache__/agent_monitor.cpython-312.pyc +0 -0
  9. package/.agents/tools/agent-job/agent_job.py +0 -936
  10. package/.agents/tools/agent-job/agent_monitor.py +0 -575
  11. package/.agents/tools/agent-job/consultant_template.txt +0 -73
  12. package/.agents/tools/chrome-devtools/README.md +0 -101
  13. package/.agents/tools/chrome-devtools/browsertools.py +0 -839
  14. package/.agents/tools/chrome-devtools/config.json +0 -24
  15. package/.agents/tools/workflow/CONSUMER_SETUP.md +0 -114
  16. package/.agents/tools/workflow/ONBOARD.md +0 -167
  17. package/.agents/tools/workflow/README.md +0 -169
  18. package/.agents/tools/workflow/cli.ts +0 -726
  19. package/.agents/tools/workflow/config.example.json +0 -16
  20. package/.agents/tools/workflow/init.ts +0 -76
  21. package/.agents/tools/workflow/lib/file-tracker.test.ts +0 -81
  22. package/.agents/tools/workflow/lib/file-tracker.ts +0 -387
  23. package/.agents/tools/workflow/lib/harness-executor.ts +0 -649
  24. package/.agents/tools/workflow/lib/prompts.scenario.test.ts +0 -262
  25. package/.agents/tools/workflow/lib/prompts.ts +0 -548
  26. package/.agents/tools/workflow/lib/streams.test.ts +0 -140
  27. package/.agents/tools/workflow/lib/streams.ts +0 -254
  28. package/.agents/tools/workflow/runner.ts +0 -994
  29. package/.agents/tools/workflow/templates/document.md +0 -42
  30. package/.agents/tools/workflow/templates/implement.md +0 -117
  31. package/.agents/tools/workflow/templates/plan.md +0 -54
  32. package/.agents/tools/workflow/templates/pm.md +0 -84
  33. package/.agents/tools/workflow/templates/product-owner.md +0 -211
  34. package/.agents/tools/workflow/templates/review.md +0 -56
  35. package/.agents/tools/workflow/templates/uat.md +0 -71
  36. package/.claude/agents/core/engineer.md +0 -146
  37. package/.claude/commands/action-feedback-item.md +0 -27
  38. package/.claude/commands/install-feedback-widget.md +0 -599
  39. package/.claude/commands/learn.md +0 -32
  40. package/.claude/hooks/comms/__pycache__/register_subagent.cpython-312.pyc +0 -0
  41. package/.claude/hooks/comms/__pycache__/update_subagent_completion.cpython-312.pyc +0 -0
  42. package/.claude/hooks/comms/get_unread_messages.py +0 -71
  43. package/.claude/hooks/comms/register_subagent.py +0 -74
  44. package/.claude/hooks/comms/send_message.py +0 -87
  45. package/.claude/hooks/comms/update_subagent_completion.py +0 -126
  46. package/.claude/hooks/context/session_id.py +0 -35
  47. package/.claude/hooks/observability/send_event.py +0 -110
  48. package/.claude/hooks/session-data/get_session_data.py +0 -261
  49. package/.claude/hooks/session-data/intercept_session_id.py +0 -69
  50. package/.claude/hooks/utils/__pycache__/server_config.cpython-312.pyc +0 -0
  51. package/.claude/hooks/utils/server_config.py +0 -17
  52. package/.claude/settings.json +0 -108
  53. package/.claude/settings.local.json +0 -46
  54. package/CLAUDE.md +0 -240
@@ -1,839 +0,0 @@
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
- import time
25
- import uuid
26
- from pathlib import Path
27
- from typing import Any, Dict, Optional
28
-
29
- # State directory
30
- STATE_DIR = Path.home() / ".browsertools"
31
- CONFIG_FILE = STATE_DIR / "config.json"
32
- INACTIVITY_TIMEOUT = 600 # 10 minutes
33
-
34
- # Global daemon state
35
- mcp_proc = None
36
- mcp_reader = None
37
- mcp_writer = None
38
- pending_requests = {} # msg_id -> response_future
39
- last_activity_time = time.time()
40
- current_instance_id = None
41
-
42
-
43
- def generate_instance_id() -> str:
44
- """Generate 8-char hex instance ID."""
45
- return uuid.uuid4().hex[:8]
46
-
47
-
48
- def get_socket_path(instance_id: str) -> Path:
49
- """Get socket path for instance."""
50
- return STATE_DIR / f"daemon-{instance_id}.sock"
51
-
52
-
53
- def get_pid_file(instance_id: str) -> Path:
54
- """Get PID file path for instance."""
55
- return STATE_DIR / f"daemon-{instance_id}.pid"
56
-
57
-
58
- def ensure_state_dir():
59
- """Create state directory."""
60
- STATE_DIR.mkdir(exist_ok=True)
61
-
62
-
63
- def get_daemon_pid(instance_id: str) -> Optional[int]:
64
- """Get PID of running daemon for instance."""
65
- pid_file = get_pid_file(instance_id)
66
- if not pid_file.exists():
67
- return None
68
- try:
69
- pid = int(pid_file.read_text().strip())
70
- os.kill(pid, 0) # Check if alive
71
- return pid
72
- except (ProcessLookupError, ValueError):
73
- pid_file.unlink(missing_ok=True)
74
- return None
75
-
76
-
77
- def is_daemon_running(instance_id: str) -> bool:
78
- """Check if daemon is running for instance."""
79
- return get_daemon_pid(instance_id) is not None and get_socket_path(instance_id).exists()
80
-
81
-
82
- def get_all_instances() -> list[str]:
83
- """Get list of all running instance IDs."""
84
- instances = []
85
- if not STATE_DIR.exists():
86
- return instances
87
- for pid_file in STATE_DIR.glob("daemon-*.pid"):
88
- instance_id = pid_file.stem.replace("daemon-", "")
89
- if is_daemon_running(instance_id):
90
- instances.append(instance_id)
91
- else:
92
- # Clean up stale files
93
- pid_file.unlink(missing_ok=True)
94
- get_socket_path(instance_id).unlink(missing_ok=True)
95
- return instances
96
-
97
-
98
- def load_config() -> Dict[str, Any]:
99
- """Load config file or return defaults."""
100
- # Try user config first
101
- if CONFIG_FILE.exists():
102
- try:
103
- with open(CONFIG_FILE) as f:
104
- return json.load(f)
105
- except Exception as e:
106
- print(f"Warning: Failed to load user config: {e}", file=sys.stderr)
107
-
108
- # Try repo default config
109
- repo_config = Path(__file__).parent / "config.json"
110
- if repo_config.exists():
111
- try:
112
- with open(repo_config) as f:
113
- return json.load(f)
114
- except Exception as e:
115
- print(f"Warning: Failed to load repo config: {e}", file=sys.stderr)
116
-
117
- # Fallback to hardcoded defaults
118
- return {
119
- "mcp_command": "npx",
120
- "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--headless=true"]
121
- }
122
-
123
-
124
- def save_default_config():
125
- """Save default config file."""
126
- ensure_state_dir()
127
- config = {
128
- "mcp_command": "npx",
129
- "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated"],
130
- "_comment": "Customize MCP server startup. Examples:",
131
- "_example_headless": {
132
- "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--headless=true"]
133
- },
134
- "_example_custom_chrome": {
135
- "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--executablePath=/usr/local/bin/chromium-mcp"]
136
- },
137
- "_example_sandbox": {
138
- "mcp_args": ["-y", "chrome-devtools-mcp@latest", "--isolated=true", "--headless=true", "--executablePath=/usr/local/bin/chromium-mcp"]
139
- }
140
- }
141
- with open(CONFIG_FILE, "w") as f:
142
- json.dump(config, f, indent=2)
143
- print(f"Created config file: {CONFIG_FILE}", file=sys.stderr)
144
-
145
-
146
- # ============================================================================
147
- # DAEMON MODE - Bridge between Unix socket and MCP stdio
148
- # ============================================================================
149
-
150
- async def read_mcp_responses():
151
- """Read responses from MCP stdout and route to clients."""
152
- global mcp_reader, pending_requests
153
-
154
- while True:
155
- try:
156
- # Read line with increased limit for large snapshots
157
- line = await mcp_reader.readline()
158
- if not line:
159
- print("MCP server closed stdout", file=sys.stderr)
160
- break
161
-
162
- try:
163
- resp = json.loads(line.decode())
164
- msg_id = resp.get("id")
165
-
166
- # Route response to waiting client
167
- if msg_id and msg_id in pending_requests:
168
- future = pending_requests.pop(msg_id)
169
- future.set_result(resp)
170
- except json.JSONDecodeError as e:
171
- print(f"Invalid JSON from MCP: {e}", file=sys.stderr)
172
- print(f"Line length: {len(line)}", file=sys.stderr)
173
-
174
- except Exception as e:
175
- print(f"Error reading MCP: {e}", file=sys.stderr)
176
- # Don't break on limit errors, just log and continue
177
- if "longer than limit" not in str(e):
178
- break
179
-
180
-
181
- async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
182
- """Handle a client connection."""
183
- global mcp_writer, pending_requests, last_activity_time
184
-
185
- # Update activity timestamp
186
- last_activity_time = time.time()
187
-
188
- try:
189
- while True:
190
- # Read request from client
191
- line = await reader.readline()
192
- if not line:
193
- break
194
-
195
- try:
196
- req = json.loads(line.decode())
197
- msg_id = req.get("id")
198
-
199
- # Create future for response
200
- response_future = asyncio.Future()
201
- pending_requests[msg_id] = response_future
202
-
203
- # Forward to MCP server
204
- mcp_writer.write(line)
205
- await mcp_writer.drain()
206
-
207
- # Wait for response
208
- try:
209
- resp = await asyncio.wait_for(response_future, timeout=30.0)
210
- writer.write(json.dumps(resp).encode() + b'\n')
211
- await writer.drain()
212
- except asyncio.TimeoutError:
213
- writer.write(json.dumps({
214
- "jsonrpc": "2.0",
215
- "id": msg_id,
216
- "error": {"code": -1, "message": "Timeout waiting for MCP response"}
217
- }).encode() + b'\n')
218
- await writer.drain()
219
- pending_requests.pop(msg_id, None)
220
-
221
- except json.JSONDecodeError:
222
- print(f"Invalid JSON from client: {line}", file=sys.stderr)
223
-
224
- except Exception as e:
225
- print(f"Client handler error: {e}", file=sys.stderr)
226
- finally:
227
- writer.close()
228
-
229
-
230
- async def shutdown_mcp():
231
- """Properly shutdown MCP server and all Chrome child processes."""
232
- global mcp_proc
233
-
234
- if not mcp_proc:
235
- return
236
-
237
- print("Shutting down MCP server and Chrome...", file=sys.stderr)
238
-
239
- try:
240
- # Get process group ID
241
- pgid = os.getpgid(mcp_proc.pid)
242
-
243
- # Send SIGTERM to entire process group (kills Chrome and all children)
244
- os.killpg(pgid, signal.SIGTERM)
245
-
246
- # Wait for graceful shutdown
247
- try:
248
- await asyncio.wait_for(mcp_proc.wait(), timeout=5.0)
249
- print("MCP server stopped gracefully", file=sys.stderr)
250
- except asyncio.TimeoutError:
251
- # Force kill if still running
252
- print("Force killing MCP server...", file=sys.stderr)
253
- os.killpg(pgid, signal.SIGKILL)
254
- await mcp_proc.wait()
255
-
256
- except ProcessLookupError:
257
- # Already dead
258
- pass
259
- except Exception as e:
260
- print(f"Error during shutdown: {e}", file=sys.stderr)
261
-
262
-
263
- async def inactivity_monitor(shutdown_callback):
264
- """Monitor for inactivity and trigger shutdown after timeout."""
265
- global last_activity_time
266
-
267
- while True:
268
- await asyncio.sleep(10) # Check every 10 seconds for responsive timeout
269
- inactive_time = time.time() - last_activity_time
270
- if inactive_time > INACTIVITY_TIMEOUT:
271
- await shutdown_callback()
272
- return
273
-
274
-
275
- async def run_daemon(instance_id: str):
276
- """Run the persistent daemon."""
277
- global mcp_proc, mcp_reader, mcp_writer, current_instance_id, last_activity_time
278
-
279
- current_instance_id = instance_id
280
- last_activity_time = time.time()
281
- ensure_state_dir()
282
-
283
- socket_path = get_socket_path(instance_id)
284
- pid_file = get_pid_file(instance_id)
285
-
286
- # Clean up old socket if exists
287
- if socket_path.exists():
288
- socket_path.unlink()
289
-
290
- # Load config
291
- config = load_config()
292
- mcp_command = config.get("mcp_command", "npx")
293
- mcp_args = config.get("mcp_args", ["-y", "chrome-devtools-mcp@latest", "--isolated"])
294
-
295
- print(f"Starting chrome-devtools-mcp...", file=sys.stderr)
296
- print(f"Command: {mcp_command} {' '.join(mcp_args)}", file=sys.stderr)
297
-
298
- # Start MCP server as subprocess in new process group
299
- # This ensures we can kill Chrome and all its children together
300
- mcp_proc = await asyncio.create_subprocess_exec(
301
- mcp_command, *mcp_args,
302
- stdin=asyncio.subprocess.PIPE,
303
- stdout=asyncio.subprocess.PIPE,
304
- stderr=sys.stderr,
305
- start_new_session=True, # Creates new process group
306
- limit=1024 * 1024 * 10, # 10MB limit for large snapshots
307
- )
308
-
309
- mcp_reader = mcp_proc.stdout
310
- mcp_writer = mcp_proc.stdin
311
-
312
- # Write PID file
313
- pid_file.write_text(str(os.getpid()))
314
-
315
- print(f"Starting Unix socket server at {socket_path}...", file=sys.stderr)
316
-
317
- # Start Unix socket server
318
- server = await asyncio.start_unix_server(
319
- handle_client,
320
- path=str(socket_path)
321
- )
322
-
323
- print(f"Daemon ready. Instance: {instance_id} PID: {os.getpid()}", file=sys.stderr)
324
-
325
- # Output instance ID to stdout for capture (all other output goes to stderr)
326
- print(instance_id)
327
-
328
- # Setup signal handlers for graceful shutdown
329
- loop = asyncio.get_event_loop()
330
- shutdown_triggered = asyncio.Event()
331
-
332
- async def handle_shutdown(sig=None):
333
- if shutdown_triggered.is_set():
334
- return
335
- shutdown_triggered.set()
336
- if sig:
337
- print(f"\nReceived {sig.name}, shutting down...", file=sys.stderr)
338
- await shutdown_mcp()
339
- server.close()
340
- await server.wait_closed()
341
- socket_path.unlink(missing_ok=True)
342
- pid_file.unlink(missing_ok=True)
343
- # Exit cleanly instead of stopping loop (avoids RuntimeError)
344
- os._exit(0)
345
-
346
- for sig in (signal.SIGTERM, signal.SIGINT):
347
- loop.add_signal_handler(
348
- sig,
349
- lambda s=sig: asyncio.create_task(handle_shutdown(s))
350
- )
351
-
352
- # Run socket server, MCP response reader, and inactivity monitor
353
- try:
354
- await asyncio.gather(
355
- server.serve_forever(),
356
- read_mcp_responses(),
357
- inactivity_monitor(handle_shutdown),
358
- )
359
- except KeyboardInterrupt:
360
- print("\nShutting down daemon...", file=sys.stderr)
361
- finally:
362
- # Cleanup
363
- if not shutdown_triggered.is_set():
364
- await shutdown_mcp()
365
- server.close()
366
- await server.wait_closed()
367
- socket_path.unlink(missing_ok=True)
368
- pid_file.unlink(missing_ok=True)
369
-
370
-
371
- # ============================================================================
372
- # CLIENT MODE - Connect to daemon and send commands
373
- # ============================================================================
374
-
375
- async def send_command(instance_id: str, tool_name: str, args: Dict[str, Any]) -> Any:
376
- """Send a command to the daemon via socket."""
377
- if not is_daemon_running(instance_id):
378
- raise RuntimeError(f"Daemon instance '{instance_id}' not running. Start with: browsertools.py daemon start")
379
-
380
- socket_path = get_socket_path(instance_id)
381
-
382
- # Connect to daemon's Unix socket with increased limit for large responses
383
- reader, writer = await asyncio.open_unix_connection(
384
- str(socket_path),
385
- limit=1024 * 1024 * 10 # 10MB limit for large snapshots
386
- )
387
-
388
- try:
389
- # Create JSON-RPC request
390
- msg_id = os.urandom(8).hex()
391
- request = {
392
- "jsonrpc": "2.0",
393
- "method": "tools/call",
394
- "params": {
395
- "name": tool_name,
396
- "arguments": args
397
- },
398
- "id": msg_id
399
- }
400
-
401
- # Send request
402
- writer.write(json.dumps(request).encode() + b'\n')
403
- await writer.drain()
404
-
405
- # Read response
406
- line = await reader.readline()
407
- response = json.loads(line.decode())
408
-
409
- if "error" in response:
410
- raise RuntimeError(f"MCP error: {response['error']}")
411
-
412
- return response.get("result")
413
-
414
- finally:
415
- writer.close()
416
- await writer.wait_closed()
417
-
418
-
419
- def clean_output(result: Any) -> str:
420
- """Extract clean text from MCP result."""
421
- if isinstance(result, dict):
422
- if "content" in result:
423
- content = result["content"]
424
- if isinstance(content, list):
425
- texts = []
426
- for item in content:
427
- if isinstance(item, dict):
428
- if "text" in item:
429
- texts.append(item["text"])
430
- elif item.get("type") == "image" and "data" in item:
431
- # Return base64 image data
432
- mime = item.get("mimeType", "image/png")
433
- b64_data = item["data"]
434
- texts.append(f"\n## Base64 Image ({mime})")
435
- texts.append(b64_data)
436
-
437
- return "\n".join(texts) if texts else str(content)
438
- elif "text" in result:
439
- return result["text"]
440
- return str(result)
441
-
442
-
443
- async def execute_command(instance_id: str, cmd: str, cmd_args: Dict[str, Any]):
444
- """Execute a single command via daemon."""
445
-
446
- # Map commands to MCP tools
447
- tool_name = None
448
- tool_args = {}
449
-
450
- if cmd == "nav":
451
- tool_name = "navigate_page"
452
- tool_args = {"url": cmd_args["url"]}
453
- if "timeout" in cmd_args:
454
- tool_args["timeout"] = cmd_args["timeout"]
455
- elif cmd == "snap":
456
- tool_name = "take_snapshot"
457
- elif cmd == "click":
458
- tool_name = "click"
459
- tool_args = {"uid": cmd_args["uid"]}
460
- elif cmd == "fill":
461
- tool_name = "fill"
462
- tool_args = {"uid": cmd_args["uid"], "value": cmd_args["value"]}
463
- elif cmd == "shot":
464
- tool_name = "take_screenshot"
465
- if "path" in cmd_args:
466
- tool_args["filePath"] = cmd_args["path"]
467
- elif cmd == "wait":
468
- tool_name = "wait_for"
469
- tool_args = {"text": cmd_args["text"]}
470
- if "timeout" in cmd_args:
471
- tool_args["timeout"] = cmd_args["timeout"]
472
- elif cmd == "eval":
473
- tool_name = "evaluate_script"
474
- tool_args = {"function": cmd_args["function"]}
475
- if "args" in cmd_args:
476
- tool_args["args"] = cmd_args["args"]
477
- elif cmd == "key":
478
- tool_name = "press_key"
479
- tool_args = {"key": cmd_args["key"]}
480
- elif cmd == "hover":
481
- tool_name = "hover"
482
- tool_args = {"uid": cmd_args["uid"]}
483
- elif cmd == "netlist":
484
- tool_name = "list_network_requests"
485
- if "resource_types" in cmd_args:
486
- tool_args["resourceTypes"] = cmd_args["resource_types"]
487
- if "page_size" in cmd_args:
488
- tool_args["pageSize"] = cmd_args["page_size"]
489
- elif cmd == "netget":
490
- tool_name = "get_network_request"
491
- if "reqid" in cmd_args:
492
- tool_args["reqid"] = cmd_args["reqid"]
493
- elif cmd == "conslist":
494
- tool_name = "list_console_messages"
495
- if "types" in cmd_args:
496
- tool_args["types"] = cmd_args["types"]
497
- if "page_size" in cmd_args:
498
- tool_args["pageSize"] = cmd_args["page_size"]
499
- elif cmd == "consget":
500
- tool_name = "get_console_message"
501
- tool_args = {"msgid": cmd_args["msgid"]}
502
- elif cmd == "resize":
503
- tool_name = "resize_page"
504
- tool_args = {"width": cmd_args["width"], "height": cmd_args["height"]}
505
- elif cmd == "dialog":
506
- tool_name = "handle_dialog"
507
- tool_args = {"action": cmd_args["action"]}
508
- if "prompt_text" in cmd_args:
509
- tool_args["promptText"] = cmd_args["prompt_text"]
510
- elif cmd == "upload":
511
- tool_name = "upload_file"
512
- tool_args = {"uid": cmd_args["uid"], "filePath": cmd_args["file_path"]}
513
- elif cmd == "drag":
514
- tool_name = "drag"
515
- tool_args = {"from_uid": cmd_args["from_uid"], "to_uid": cmd_args["to_uid"]}
516
- elif cmd == "fillform":
517
- tool_name = "fill_form"
518
- tool_args = {"elements": cmd_args["elements"]}
519
- else:
520
- raise ValueError(f"Unknown command: {cmd}")
521
-
522
- # Send to daemon
523
- result = await send_command(instance_id, tool_name, tool_args)
524
- return clean_output(result)
525
-
526
-
527
- # ============================================================================
528
- # CLI INTERFACE
529
- # ============================================================================
530
-
531
- async def main():
532
- parser = argparse.ArgumentParser(
533
- prog="browsertools.py",
534
- description="Chrome DevTools MCP wrapper - multi-instance daemon mode",
535
- formatter_class=argparse.RawDescriptionHelpFormatter,
536
- epilog=r"""
537
- browsertools.py - Chrome DevTools automation tool
538
-
539
- Usage: uv run .agents/tools/chrome-devtools/browsertools.py <command>
540
-
541
- DAEMON MANAGEMENT:
542
- daemon start Start new daemon instance
543
- Returns: "browsertools daemon started. Instance ID: <id>"
544
- daemon stop <id> Stop daemon. kill chrome cleanly.
545
- daemon config Create config file
546
-
547
- ALL OTHER COMMANDS REQUIRE --instance <id>:
548
- --instance <id> Required for all browser commands
549
-
550
- NAVIGATION:
551
- nav <url> Navigate to URL
552
- --timeout <ms> Max wait time in milliseconds
553
- wait <text> Wait for text to appear
554
- --timeout <ms> Max wait time in milliseconds
555
-
556
- INSPECTION:
557
- snap Take a11y tree snapshot (get element UIDs)
558
- IMPORTANT: Always use latest snapshot for UIDs
559
- shot [path] Screenshot viewport (optional: save to path)
560
- eval <js> Execute JavaScript, returns JSON
561
- --args <json> JSON array of element references (advanced)
562
-
563
- INTERACTION:
564
- click <uid> Click element by UID from snapshot
565
- fill <uid> <value> Fill input/textarea or select option
566
- key <key> Press key or combo (e.g., "Enter", "Tab", "Control+A")
567
- hover <uid> Hover over element
568
- drag <from> <to> Drag element from UID to target UID
569
- fillform <json> Fill multiple form fields
570
-
571
- DEBUGGING:
572
- conslist List console messages (--types, --size)
573
- consget <msgid> Get console message details
574
- netlist List network requests (--types, --size)
575
- netget [reqid] Get request details
576
-
577
- PAGE MANIPULATION:
578
- resize <w> <h> Resize viewport
579
- dialog <action> Handle alert/confirm/prompt (accept|dismiss)
580
- upload <uid> <path> Upload file
581
-
582
- USAGE TIPS:
583
- **Snapshots** are verbose. Always try to pipe grep first
584
- - Default pattern: snap | grep -iC5 "search term"
585
- - **Interaction commands** return updated page state snapshots: click, fill, key, hover, drag, wait return updated page state
586
- - Pipe to grep with context. eg. click 1_7 | grep -iC5 "dashboard\|home"
587
- - No grep hits? Run explicit snap to see full page
588
-
589
- Limit output for **debugging** commands
590
- - Network: netlist --size 10 --types fetch
591
- - Console: conslist --size 5 --types error
592
-
593
- EXAMPLES:
594
- # Start daemon (backgrounds automatically, prints instance ID)
595
- uv run .agents/tools/chrome-devtools/browsertools.py daemon start
596
- # Output: browsertools daemon started. Instance ID: a3f7b2c1
597
-
598
- # Use instance for all commands
599
- uv run .agents/tools/chrome-devtools/browsertools.py --instance a3f7b2c1 nav http://app.com/login
600
- uv run .agents/tools/chrome-devtools/browsertools.py --instance a3f7b2c1 snap | grep -iC5 "email\|username\|login"
601
- uv run .agents/tools/chrome-devtools/browsertools.py --instance a3f7b2c1 fill 1_23 user@example.com
602
- uv run .agents/tools/chrome-devtools/browsertools.py --instance a3f7b2c1 snap | grep -iC5 "button.*(submit|login|sign.?in)"
603
- uv run .agents/tools/chrome-devtools/browsertools.py --instance a3f7b2c1 click 1_25 | grep -iC5 "dashboard\|home\|welcome"
604
-
605
- # Stop when finished to clean up resources
606
- uv run .agents/tools/chrome-devtools/browsertools.py daemon stop a3f7b2c1
607
- """
608
- )
609
-
610
- # Global --instance argument
611
- parser.add_argument("--instance", "-i", help="Daemon instance ID (required for browser commands)")
612
-
613
- subparsers = parser.add_subparsers(dest="cmd", required=True)
614
-
615
- # Daemon management
616
- daemon = subparsers.add_parser("daemon", help="Manage daemon")
617
- daemon_sub = daemon.add_subparsers(dest="daemon_cmd", required=True)
618
- daemon_sub.add_parser("start", help="Start new daemon instance (returns instance ID)")
619
- stop_parser = daemon_sub.add_parser("stop", help="Stop daemon instance")
620
- stop_parser.add_argument("instance_id", nargs="?", help="Instance ID to stop")
621
- stop_parser.add_argument("--all", dest="stop_all", action="store_true", help=argparse.SUPPRESS) # Hidden
622
- daemon_sub.add_parser("config", help="Create default config file")
623
-
624
- # Browser commands
625
- nav = subparsers.add_parser("nav", help="Navigate to URL")
626
- nav.add_argument("url", help="URL to navigate to")
627
- nav.add_argument("--timeout", type=int, help="Max wait time in milliseconds")
628
-
629
- subparsers.add_parser("snap", help="Take page snapshot")
630
-
631
- shot = subparsers.add_parser("shot", help="Take screenshot")
632
- shot.add_argument("path", nargs="?", help="Save path (optional)")
633
-
634
- click = subparsers.add_parser("click", help="Click element")
635
- click.add_argument("uid", help="Element UID from snapshot")
636
-
637
- fill = subparsers.add_parser("fill", help="Fill input")
638
- fill.add_argument("uid", help="Element UID")
639
- fill.add_argument("value", help="Value to fill")
640
-
641
- wait = subparsers.add_parser("wait", help="Wait for text")
642
- wait.add_argument("text", help="Text to wait for")
643
- wait.add_argument("--timeout", type=int, help="Max wait time in milliseconds")
644
-
645
- eval_parser = subparsers.add_parser("eval", help="Execute JavaScript")
646
- eval_parser.add_argument("function", help="JS function to execute")
647
- eval_parser.add_argument("--args", help="JSON array of element UIDs to pass as arguments")
648
-
649
- # Keyboard & mouse
650
- key = subparsers.add_parser("key", help="Press keyboard key")
651
- key.add_argument("key", help="Key to press (e.g., 'Enter', 'Tab', 'Escape')")
652
-
653
- hover_parser = subparsers.add_parser("hover", help="Hover over element")
654
- hover_parser.add_argument("uid", help="Element UID from snapshot")
655
-
656
- # Network debugging
657
- netlist = subparsers.add_parser("netlist", help="List network requests")
658
- netlist.add_argument("--types", nargs="+", help="Filter by resource types")
659
- netlist.add_argument("--size", type=int, help="Max requests to return")
660
-
661
- netget = subparsers.add_parser("netget", help="Get network request details")
662
- netget.add_argument("reqid", nargs="?", type=int, help="Request ID (omit for latest)")
663
-
664
- # Console debugging
665
- conslist = subparsers.add_parser("conslist", help="List console messages")
666
- conslist.add_argument("--types", nargs="+", help="Filter by types (log, error, warn)")
667
- conslist.add_argument("--size", type=int, help="Max messages to return")
668
-
669
- consget = subparsers.add_parser("consget", help="Get console message details")
670
- consget.add_argument("msgid", type=int, help="Message ID")
671
-
672
- # Page manipulation
673
- resize = subparsers.add_parser("resize", help="Resize page viewport")
674
- resize.add_argument("width", type=int, help="Width in pixels")
675
- resize.add_argument("height", type=int, help="Height in pixels")
676
-
677
- dialog = subparsers.add_parser("dialog", help="Handle browser dialog (alert/confirm/prompt)")
678
- dialog.add_argument("action", choices=["accept", "dismiss"], help="Accept or dismiss dialog")
679
- dialog.add_argument("--text", dest="prompt_text", help="Text for prompt dialog")
680
-
681
- upload = subparsers.add_parser("upload", help="Upload file through element")
682
- upload.add_argument("uid", help="File input element UID")
683
- upload.add_argument("file_path", help="Local file path to upload")
684
-
685
- # Advanced interaction
686
- drag_parser = subparsers.add_parser("drag", help="Drag element to target")
687
- drag_parser.add_argument("from_uid", help="Source element UID")
688
- drag_parser.add_argument("to_uid", help="Target element UID")
689
-
690
- fillform_parser = subparsers.add_parser("fillform", help="Fill multiple form fields")
691
- fillform_parser.add_argument("elements", help="JSON array: [{'uid':'1_1','value':'text'}]")
692
-
693
- args = parser.parse_args()
694
-
695
- # Handle daemon commands
696
- if args.cmd == "daemon":
697
- if args.daemon_cmd == "start":
698
- # Generate new instance ID
699
- instance_id = generate_instance_id()
700
-
701
- # Fork to background
702
- pid = os.fork()
703
-
704
- if pid > 0:
705
- # Parent process - print result and exit
706
- # Give child a moment to start
707
- time.sleep(0.5)
708
- print(f"browsertools daemon started. Instance ID: {instance_id}")
709
- return # Exit from async main, let asyncio.run() clean up
710
- else:
711
- # Child process - detach and run daemon
712
- os.setsid() # Create new session, detach from terminal
713
-
714
- # Redirect stdin/stdout/stderr to /dev/null (daemon runs silently)
715
- devnull_r = os.open(os.devnull, os.O_RDONLY)
716
- devnull_w = os.open(os.devnull, os.O_WRONLY)
717
- os.dup2(devnull_r, sys.stdin.fileno())
718
- os.dup2(devnull_w, sys.stdout.fileno())
719
- os.dup2(devnull_w, sys.stderr.fileno())
720
- os.close(devnull_r)
721
- os.close(devnull_w)
722
-
723
- # Create fresh event loop for child (parent's loop is unusable after fork)
724
- asyncio.set_event_loop(asyncio.new_event_loop())
725
-
726
- # Run the daemon in the new event loop (this blocks until shutdown)
727
- asyncio.run(run_daemon(instance_id))
728
- os._exit(0)
729
-
730
- elif args.daemon_cmd == "stop":
731
- if args.stop_all:
732
- # Hidden --all flag: stop all running instances
733
- instances = get_all_instances()
734
- if not instances:
735
- print("No running instances", file=sys.stderr)
736
- sys.exit(1)
737
- for inst_id in instances:
738
- pid = get_daemon_pid(inst_id)
739
- if pid:
740
- os.kill(pid, signal.SIGTERM)
741
- print(f"Stopped instance {inst_id} (PID {pid})", file=sys.stderr)
742
- elif args.instance_id:
743
- # Stop specific instance
744
- pid = get_daemon_pid(args.instance_id)
745
- if not pid:
746
- print(f"Instance '{args.instance_id}' not running", file=sys.stderr)
747
- sys.exit(1)
748
- os.kill(pid, signal.SIGTERM)
749
- print(f"Stopped instance {args.instance_id} (PID {pid})", file=sys.stderr)
750
- else:
751
- print("Error: instance_id required. Usage: daemon stop <instance_id>", file=sys.stderr)
752
- sys.exit(1)
753
-
754
- elif args.daemon_cmd == "config":
755
- save_default_config()
756
- print(f"\nEdit {CONFIG_FILE} to customize MCP server settings.", file=sys.stderr)
757
- print("\nExamples:", file=sys.stderr)
758
- print(" Headless mode: --headless=true", file=sys.stderr)
759
- print(" Custom Chrome: --executablePath=/path/to/chrome", file=sys.stderr)
760
-
761
- return
762
-
763
- # Execute browser command - requires --instance
764
- if not args.instance:
765
- print("Error: --instance <id> is required for browser commands", file=sys.stderr)
766
- print("Start a daemon first: uv run browsertools.py daemon start", file=sys.stderr)
767
- sys.exit(1)
768
-
769
- if not is_daemon_running(args.instance):
770
- print(f"Error: Instance '{args.instance}' is not running", file=sys.stderr)
771
- sys.exit(1)
772
-
773
- try:
774
- cmd_args_dict = {}
775
-
776
- if args.cmd == "nav":
777
- cmd_args_dict["url"] = args.url
778
- if args.timeout:
779
- cmd_args_dict["timeout"] = args.timeout
780
- elif args.cmd == "click":
781
- cmd_args_dict["uid"] = args.uid
782
- elif args.cmd == "fill":
783
- cmd_args_dict["uid"] = args.uid
784
- cmd_args_dict["value"] = args.value
785
- elif args.cmd == "shot" and args.path:
786
- cmd_args_dict["path"] = args.path
787
- elif args.cmd == "wait":
788
- cmd_args_dict["text"] = args.text
789
- if args.timeout:
790
- cmd_args_dict["timeout"] = args.timeout
791
- elif args.cmd == "eval":
792
- cmd_args_dict["function"] = args.function
793
- if args.args:
794
- cmd_args_dict["args"] = json.loads(args.args)
795
- elif args.cmd == "key":
796
- cmd_args_dict["key"] = args.key
797
- elif args.cmd == "hover":
798
- cmd_args_dict["uid"] = args.uid
799
- elif args.cmd == "netlist":
800
- if args.types:
801
- cmd_args_dict["resource_types"] = args.types
802
- if args.size:
803
- cmd_args_dict["page_size"] = args.size
804
- elif args.cmd == "netget":
805
- if args.reqid:
806
- cmd_args_dict["reqid"] = args.reqid
807
- elif args.cmd == "conslist":
808
- if args.types:
809
- cmd_args_dict["types"] = args.types
810
- if args.size:
811
- cmd_args_dict["page_size"] = args.size
812
- elif args.cmd == "consget":
813
- cmd_args_dict["msgid"] = args.msgid
814
- elif args.cmd == "resize":
815
- cmd_args_dict["width"] = args.width
816
- cmd_args_dict["height"] = args.height
817
- elif args.cmd == "dialog":
818
- cmd_args_dict["action"] = args.action
819
- if args.prompt_text:
820
- cmd_args_dict["prompt_text"] = args.prompt_text
821
- elif args.cmd == "upload":
822
- cmd_args_dict["uid"] = args.uid
823
- cmd_args_dict["file_path"] = args.file_path
824
- elif args.cmd == "drag":
825
- cmd_args_dict["from_uid"] = args.from_uid
826
- cmd_args_dict["to_uid"] = args.to_uid
827
- elif args.cmd == "fillform":
828
- cmd_args_dict["elements"] = json.loads(args.elements)
829
-
830
- output = await execute_command(args.instance, args.cmd, cmd_args_dict)
831
- print(output)
832
-
833
- except Exception as e:
834
- print(f"Error: {e}", file=sys.stderr)
835
- sys.exit(1)
836
-
837
-
838
- if __name__ == "__main__":
839
- asyncio.run(main())