cdpilot 0.1.2 → 0.2.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.
package/src/cdpilot.py CHANGED
@@ -25,18 +25,202 @@ import time
25
25
  import urllib.request
26
26
  import subprocess
27
27
  import shutil
28
+ import platform
29
+ import socket
30
+ import difflib
31
+ import hashlib
32
+ import re as _re
28
33
 
29
- # ─── Session Configuration ───
30
- # cdpilot runs in its own Chrome instance on the configured CDP port.
31
- # The user's existing Chrome/browser session is not affected.
34
+ # ─── Project-Based Multi-Instance Configuration ───
35
+ # Each project directory (cwd) gets its own browser instance with
36
+ # a unique CDP port and isolated profile directory. Zero-config.
32
37
 
33
- CDP_PORT = int(os.environ.get("CDP_PORT", "9222"))
38
+ CDPILOT_HOME = os.path.expanduser("~/.cdpilot")
39
+ REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
40
+ CDPILOT_PORT_RANGE_START = 9222
41
+ CDPILOT_PORT_RANGE_END = 9322
42
+ IS_MCP_SESSION = os.environ.get("CDPILOT_MCP_SESSION") == "1"
43
+
44
+
45
+ def _get_project_id():
46
+ """Derive a deterministic project ID from the current working directory.
47
+
48
+ Prefers CDPILOT_PROJECT_ID env (set by bin/cdpilot.js based on caller's cwd)
49
+ over os.getcwd() which may differ when invoked via npx or absolute path.
50
+ """
51
+ env_id = os.environ.get("CDPILOT_PROJECT_ID")
52
+ if env_id:
53
+ return env_id
54
+ cwd = os.getcwd()
55
+ dir_name = os.path.basename(cwd)
56
+ safe_name = _re.sub(r'[^a-zA-Z0-9-]', '', dir_name)[:20]
57
+ hash_suffix = hashlib.md5(cwd.encode()).hexdigest()[:6]
58
+ return f"{safe_name}-{hash_suffix}" if safe_name else hash_suffix
59
+
60
+
61
+ def _is_port_free(port):
62
+ """Check if a port is available for binding."""
63
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
64
+ try:
65
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
66
+ s.bind(("127.0.0.1", port))
67
+ return True
68
+ except OSError:
69
+ return False
70
+
71
+
72
+ def _load_registry():
73
+ """Read the global project registry."""
74
+ try:
75
+ with open(REGISTRY_FILE) as f:
76
+ data = json.load(f)
77
+ return data.get("projects", {})
78
+ except (FileNotFoundError, json.JSONDecodeError):
79
+ return {}
80
+
81
+
82
+ def _save_registry(projects):
83
+ """Write the global project registry."""
84
+ os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
85
+ with open(REGISTRY_FILE, 'w') as f:
86
+ json.dump({"version": 1, "projects": projects}, f, indent=2)
87
+
88
+
89
+ def _register_project(project_id, port, profile_dir, pid=None):
90
+ """Register or update a project in the global registry."""
91
+ registry = _load_registry()
92
+ existing = registry.get(project_id, {})
93
+ registry[project_id] = {
94
+ "cwd": os.getcwd(),
95
+ "port": port,
96
+ "profile_dir": profile_dir,
97
+ "pid": pid,
98
+ "created": existing.get("created", time.strftime("%Y-%m-%dT%H:%M:%S")),
99
+ "last_used": time.strftime("%Y-%m-%dT%H:%M:%S"),
100
+ "status": "running" if pid else "stopped",
101
+ }
102
+ _save_registry(registry)
103
+
104
+
105
+ def _cleanup_registry():
106
+ """Update status for dead processes and return cleaned registry."""
107
+ registry = _load_registry()
108
+ changed = False
109
+ for pid_key, info in registry.items():
110
+ if info.get("status") == "running":
111
+ port = info.get("port")
112
+ if port and _is_port_free(port):
113
+ info["status"] = "stopped"
114
+ info["pid"] = None
115
+ changed = True
116
+ if changed:
117
+ _save_registry(registry)
118
+ return registry
119
+
120
+
121
+ def _allocate_port(project_id):
122
+ """Find a free port for the given project."""
123
+ registry = _load_registry()
124
+
125
+ # Reuse existing port if still free
126
+ if project_id in registry:
127
+ existing_port = registry[project_id].get("port")
128
+ if existing_port and _is_port_free(existing_port):
129
+ return existing_port
130
+
131
+ # Collect ports used by other active projects
132
+ used_ports = set()
133
+ for pid, info in registry.items():
134
+ if pid != project_id and info.get("port"):
135
+ used_ports.add(info["port"])
136
+
137
+ # Find first free port in range
138
+ for port in range(CDPILOT_PORT_RANGE_START, CDPILOT_PORT_RANGE_END):
139
+ if port not in used_ports and _is_port_free(port):
140
+ return port
141
+
142
+ raise RuntimeError(
143
+ f"No free port in range {CDPILOT_PORT_RANGE_START}-{CDPILOT_PORT_RANGE_END}"
144
+ )
145
+
146
+
147
+ def _resolve_project_config():
148
+ """Determine port, profile dir, and project ID based on cwd + env vars."""
149
+ env_port = os.environ.get("CDP_PORT")
150
+ env_profile = os.environ.get("CDPILOT_PROFILE")
151
+
152
+ # Treat CDP_PORT=0 as "auto-allocate"
153
+ has_explicit_port = env_port and env_port != "0"
154
+
155
+ # Full manual override (legacy behavior)
156
+ if has_explicit_port and env_profile:
157
+ return int(env_port), env_profile, None
158
+
159
+ project_id = _get_project_id()
160
+ registry = _load_registry()
161
+ default_profile = os.path.join(CDPILOT_HOME, "projects", project_id, "profile")
162
+
163
+ if project_id in registry:
164
+ info = registry[project_id]
165
+ port = int(env_port) if has_explicit_port else info.get("port", 9222)
166
+ profile = env_profile or info.get("profile_dir", default_profile)
167
+ return port, profile, project_id
168
+
169
+ # New project: allocate port
170
+ try:
171
+ port = int(env_port) if has_explicit_port else _allocate_port(project_id)
172
+ except RuntimeError:
173
+ port = 9222 # fallback
174
+ profile = env_profile or default_profile
175
+ return port, profile, project_id
176
+
177
+
178
+ def _migrate_legacy_profile():
179
+ """Migrate old single-profile layout to project-based layout."""
180
+ legacy_profile = os.path.join(CDPILOT_HOME, "profile")
181
+ if (os.path.isdir(legacy_profile) and not os.path.islink(legacy_profile)
182
+ and not os.path.exists(REGISTRY_FILE)):
183
+ project_id = _get_project_id()
184
+ new_dir = os.path.join(CDPILOT_HOME, "projects", project_id)
185
+ new_profile = os.path.join(new_dir, "profile")
186
+ if not os.path.exists(new_profile):
187
+ os.makedirs(new_dir, exist_ok=True)
188
+ os.rename(legacy_profile, new_profile)
189
+ os.symlink(new_profile, legacy_profile)
190
+
191
+
192
+ # Resolve project config at module load time
193
+ try:
194
+ _migrate_legacy_profile()
195
+ except Exception:
196
+ pass
197
+ CDP_PORT, PROFILE_DIR, PROJECT_ID = _resolve_project_config()
34
198
  CDP_BASE = f"http://127.0.0.1:{CDP_PORT}"
35
199
  CHROME_BIN = os.environ.get("CHROME_BIN")
36
- PROFILE_DIR = os.environ.get("CDPILOT_PROFILE", os.path.expanduser("~/.cdpilot/profile"))
37
- SCREENSHOT_DIR = "/tmp"
200
+
201
+ if platform.system() == "Windows":
202
+ SCREENSHOT_DIR = os.path.expandvars(r"%TEMP%")
203
+ else:
204
+ SCREENSHOT_DIR = "/tmp"
38
205
 
39
206
  DEV_EXTENSIONS_FILE = os.path.join(PROFILE_DIR, 'dev-extensions.json')
207
+
208
+ # ─── Auto-Wait JS Helper ───────────────────────────────────────────────────────
209
+ # Tarayıcıya inject edilir; MutationObserver ile element görünene kadar bekler.
210
+ WAIT_AND_QUERY_JS = """
211
+ window.__cdpilot_waitFor = function(selector, timeout) {
212
+ return new Promise(function(resolve) {
213
+ var el = document.querySelector(selector);
214
+ if (el) { resolve(el); return; }
215
+ var obs = new MutationObserver(function() {
216
+ var found = document.querySelector(selector);
217
+ if (found) { obs.disconnect(); resolve(found); }
218
+ });
219
+ obs.observe(document.documentElement, {childList: true, subtree: true});
220
+ setTimeout(function() { obs.disconnect(); resolve(null); }, timeout || 5000);
221
+ });
222
+ };
223
+ """
40
224
  PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
41
225
  HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
42
226
  DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
@@ -88,41 +272,100 @@ def _cleanup_stale_sessions():
88
272
  INTERCEPT_RULES = [] # list of (pattern, action, data) tuples
89
273
  DIALOG_MODE = None # 'accept', 'dismiss', or None
90
274
  _current_session_id = None # lazy init
275
+ _A11Y_REF_MAP = {} # ref_num -> backendNodeId mapping
276
+ _A11Y_REF_FILE = os.path.join(PROFILE_DIR, 'a11y-refs.json')
277
+
278
+
279
+ def _save_a11y_refs(ref_map):
280
+ """Persist a11y ref map to disk for cross-process access."""
281
+ os.makedirs(os.path.dirname(_A11Y_REF_FILE), exist_ok=True)
282
+ with open(_A11Y_REF_FILE, 'w') as f:
283
+ json.dump(ref_map, f)
284
+
285
+
286
+ def _load_a11y_refs():
287
+ """Load a11y ref map from disk."""
288
+ try:
289
+ with open(_A11Y_REF_FILE) as f:
290
+ data = json.load(f)
291
+ return {int(k): v for k, v in data.items()}
292
+ except (FileNotFoundError, json.JSONDecodeError):
293
+ return {}
91
294
 
92
295
  # ─── Visual Indicator Overlay CSS ───
93
296
 
94
297
  GLOW_CSS = """
95
298
  (function() {
96
- if (document.getElementById('cdpilot-glow')) return 'already active';
97
- const style = document.createElement('style');
98
- style.id = 'cdpilot-glow';
299
+ if (document.getElementById('cdpilot-glow-overlay')) {
300
+ document.getElementById('cdpilot-glow-overlay').style.opacity = '1';
301
+ clearTimeout(window.__cdpilot_glow_timeout);
302
+ return 'glow refreshed';
303
+ }
304
+ var style = document.createElement('style');
305
+ style.id = 'cdpilot-glow-style';
99
306
  style.textContent = `
100
307
  @keyframes cdpilot-pulse {
101
- 0%, 100% { box-shadow: inset 0 0 20px 4px rgba(34, 197, 94, 0.25), inset 0 0 60px 8px rgba(34, 197, 94, 0.08); }
102
- 50% { box-shadow: inset 0 0 30px 6px rgba(34, 197, 94, 0.35), inset 0 0 80px 12px rgba(34, 197, 94, 0.12); }
308
+ 0%, 100% { box-shadow: inset 0 0 20px 4px rgba(34,197,94,0.25), inset 0 0 60px 8px rgba(34,197,94,0.08); }
309
+ 50% { box-shadow: inset 0 0 30px 6px rgba(34,197,94,0.35), inset 0 0 80px 12px rgba(34,197,94,0.12); }
103
310
  }
104
- body::after {
105
- content: '';
106
- position: fixed;
107
- top: 0; left: 0; right: 0; bottom: 0;
108
- pointer-events: none;
109
- z-index: 2147483647;
311
+ #cdpilot-glow-overlay {
312
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
313
+ pointer-events: none; z-index: 2147483646;
110
314
  animation: cdpilot-pulse 2s ease-in-out infinite;
111
- border: 2px solid rgba(34, 197, 94, 0.3);
112
- border-radius: 0;
113
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
315
+ border: 2px solid rgba(34,197,94,0.3);
316
+ transition: opacity 1s ease;
317
+ opacity: 1;
318
+ }
319
+ #cdpilot-ai-toast {
320
+ position: fixed; bottom: -80px; left: 50%; transform: translateX(-50%);
321
+ z-index: 2147483647; background: rgba(15,0,0,0.92); color: #ef4444;
322
+ padding: 14px 28px; border-radius: 12px;
323
+ font: 600 14px/1.4 system-ui,-apple-system,sans-serif;
324
+ transition: bottom 0.4s cubic-bezier(0.34,1.56,0.64,1);
325
+ border: 1px solid rgba(239,68,68,0.4);
326
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(239,68,68,0.15);
327
+ pointer-events: none; white-space: nowrap; backdrop-filter: blur(8px);
114
328
  }
115
329
  `;
116
330
  document.head.appendChild(style);
331
+ var overlay = document.createElement('div');
332
+ overlay.id = 'cdpilot-glow-overlay';
333
+ document.body.appendChild(overlay);
334
+ var toast = document.createElement('div');
335
+ toast.id = 'cdpilot-ai-toast';
336
+ toast.textContent = '\\u26A0\\uFE0F Browser is controlled by AI — please wait';
337
+ document.body.appendChild(toast);
338
+ var _tt, _throttle = 0;
339
+ function _showWarn() {
340
+ if (!document.getElementById('cdpilot-glow-overlay')) return;
341
+ var now = Date.now();
342
+ if (now - _throttle < 2000) return;
343
+ _throttle = now;
344
+ toast.style.bottom = '24px';
345
+ clearTimeout(_tt);
346
+ _tt = setTimeout(function() { toast.style.bottom = '-80px'; }, 3000);
347
+ }
348
+ window.__cdpilot_warn = _showWarn;
349
+ document.addEventListener('mousemove', _showWarn, true);
350
+ clearTimeout(window.__cdpilot_glow_timeout);
117
351
  return 'glow active';
118
352
  })()
119
353
  """
120
354
 
121
355
  GLOW_OFF_CSS = """
122
356
  (function() {
123
- const el = document.getElementById('cdpilot-glow');
124
- if (el) { el.remove(); return 'glow off'; }
125
- return 'already off';
357
+ var overlay = document.getElementById('cdpilot-glow-overlay');
358
+ var style = document.getElementById('cdpilot-glow-style');
359
+ var toast = document.getElementById('cdpilot-ai-toast');
360
+ if (overlay) { overlay.style.opacity = '0'; setTimeout(function() { overlay.remove(); }, 1000); }
361
+ if (style) setTimeout(function() { style.remove(); }, 1100);
362
+ if (toast) toast.remove();
363
+ if (window.__cdpilot_warn) {
364
+ document.removeEventListener('mousemove', window.__cdpilot_warn, true);
365
+ delete window.__cdpilot_warn;
366
+ }
367
+ clearTimeout(window.__cdpilot_glow_timeout);
368
+ return overlay ? 'glow fading' : 'already off';
126
369
  })()
127
370
  """
128
371
 
@@ -131,38 +374,36 @@ GLOW_OFF_CSS = """
131
374
  INPUT_BLOCKER_ON = """
132
375
  (function() {
133
376
  if (document.getElementById('cdpilot-input-blocker')) return 'blocker already active';
134
- const overlay = document.createElement('div');
377
+ var overlay = document.createElement('div');
135
378
  overlay.id = 'cdpilot-input-blocker';
136
- overlay.style.cssText = `
137
- position: fixed; top: 0; left: 0; right: 0; bottom: 0;
138
- z-index: 2147483646; cursor: not-allowed;
139
- background: transparent;
140
- `;
141
- overlay.addEventListener('mousedown', e => { e.stopPropagation(); e.preventDefault(); }, true);
142
- overlay.addEventListener('mouseup', e => { e.stopPropagation(); e.preventDefault(); }, true);
143
- overlay.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); }, true);
144
- overlay.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); }, true);
145
- overlay.addEventListener('contextmenu', e => { e.stopPropagation(); e.preventDefault(); }, true);
146
- overlay.addEventListener('wheel', e => { e.stopPropagation(); e.preventDefault(); }, {capture: true, passive: false});
379
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;cursor:not-allowed;background:transparent;';
380
+ var toast = document.createElement('div');
381
+ toast.id = 'cdpilot-warning-toast';
382
+ toast.textContent = '\\u26A0\\uFE0F Browser is controlled by AI \\u2014 please wait';
383
+ toast.style.cssText = 'position:fixed;bottom:-80px;left:50%;transform:translateX(-50%);z-index:2147483647;background:rgba(15,0,0,0.92);color:#ef4444;padding:14px 28px;border-radius:12px;font:600 14px/1.4 system-ui,-apple-system,sans-serif;transition:bottom 0.4s cubic-bezier(0.34,1.56,0.64,1);border:1px solid rgba(239,68,68,0.4);box-shadow:0 4px 24px rgba(0,0,0,0.5),0 0 20px rgba(239,68,68,0.15);pointer-events:none;white-space:nowrap;backdrop-filter:blur(8px);';
384
+ document.body.appendChild(toast);
385
+ var _tt;
386
+ function _warn() {
387
+ toast.style.bottom = '24px';
388
+ clearTimeout(_tt);
389
+ _tt = setTimeout(function() { toast.style.bottom = '-80px'; }, 3000);
390
+ }
391
+ overlay.addEventListener('mousedown', function(e) { e.stopPropagation(); e.preventDefault(); _warn(); }, true);
392
+ overlay.addEventListener('mouseup', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
393
+ overlay.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); _warn(); }, true);
394
+ overlay.addEventListener('dblclick', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
395
+ overlay.addEventListener('contextmenu', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
396
+ overlay.addEventListener('wheel', function(e) { e.stopPropagation(); e.preventDefault(); }, {capture:true, passive:false});
147
397
  document.addEventListener('keydown', function _cb(e) {
148
- if (!document.getElementById('cdpilot-input-blocker')) {
149
- document.removeEventListener('keydown', _cb, true);
150
- return;
151
- }
152
- e.stopPropagation(); e.preventDefault();
398
+ if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keydown', _cb, true); return; }
399
+ e.stopPropagation(); e.preventDefault(); _warn();
153
400
  }, true);
154
401
  document.addEventListener('keyup', function _cb(e) {
155
- if (!document.getElementById('cdpilot-input-blocker')) {
156
- document.removeEventListener('keyup', _cb, true);
157
- return;
158
- }
402
+ if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keyup', _cb, true); return; }
159
403
  e.stopPropagation(); e.preventDefault();
160
404
  }, true);
161
405
  document.addEventListener('keypress', function _cb(e) {
162
- if (!document.getElementById('cdpilot-input-blocker')) {
163
- document.removeEventListener('keypress', _cb, true);
164
- return;
165
- }
406
+ if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keypress', _cb, true); return; }
166
407
  e.stopPropagation(); e.preventDefault();
167
408
  }, true);
168
409
  document.body.appendChild(overlay);
@@ -172,56 +413,207 @@ INPUT_BLOCKER_ON = """
172
413
 
173
414
  INPUT_BLOCKER_OFF = """
174
415
  (function() {
175
- const el = document.getElementById('cdpilot-input-blocker');
176
- if (el) { el.remove(); return 'input blocker off'; }
177
- return 'blocker already off';
416
+ var el = document.getElementById('cdpilot-input-blocker');
417
+ if (el) el.remove();
418
+ var toast = document.getElementById('cdpilot-warning-toast');
419
+ if (toast) toast.remove();
420
+ return el ? 'input blocker off' : 'blocker already off';
178
421
  })()
179
422
  """
180
423
 
181
- # ─── Automation Indicator Wrapper ───
424
+ # ─── Visual Feedback System (cursor, ripple, keystroke) ───
182
425
 
183
- _glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
426
+ VISUAL_FEEDBACK_JS = """
427
+ (function() {
428
+ if (window.__cdpilot_vfx) return 'vfx already active';
429
+ var style = document.createElement('style');
430
+ style.id = 'cdpilot-vfx-style';
431
+ style.textContent = `
432
+ @keyframes cdpilot-ripple-anim {
433
+ 0% { transform: translate(-50%,-50%) scale(0); opacity: 1; }
434
+ 100% { transform: translate(-50%,-50%) scale(1); opacity: 0; }
435
+ }
436
+ .cdpilot-ripple {
437
+ position: fixed; width: 50px; height: 50px;
438
+ border: 2.5px solid #22c55e; border-radius: 50%;
439
+ pointer-events: none; z-index: 2147483647;
440
+ animation: cdpilot-ripple-anim 0.6s ease-out forwards;
441
+ box-shadow: 0 0 12px rgba(34,197,94,0.4);
442
+ }
443
+ .cdpilot-ripple-inner {
444
+ position: fixed; width: 8px; height: 8px;
445
+ background: #22c55e; border-radius: 50%;
446
+ pointer-events: none; z-index: 2147483647;
447
+ transform: translate(-50%,-50%);
448
+ opacity: 0.8;
449
+ animation: cdpilot-ripple-anim 0.4s ease-out 0.1s forwards;
450
+ }
451
+ #cdpilot-cursor {
452
+ position: fixed; pointer-events: none; z-index: 2147483647;
453
+ transition: left 0.2s cubic-bezier(0.25,0.8,0.25,1), top 0.2s cubic-bezier(0.25,0.8,0.25,1);
454
+ filter: drop-shadow(0 0 4px rgba(34,197,94,0.6));
455
+ }
456
+ #cdpilot-keystroke {
457
+ position: fixed; bottom: 80px; left: 50%;
458
+ transform: translateX(-50%);
459
+ background: rgba(0,0,0,0.88); color: #22c55e;
460
+ padding: 10px 20px; border-radius: 8px;
461
+ font: 700 15px/1.4 'SF Mono',Monaco,Menlo,monospace;
462
+ pointer-events: none; z-index: 2147483647;
463
+ border: 1px solid rgba(34,197,94,0.4);
464
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4), 0 0 15px rgba(34,197,94,0.1);
465
+ opacity: 0; transition: opacity 0.3s ease;
466
+ backdrop-filter: blur(8px);
467
+ }
468
+ `;
469
+ document.head.appendChild(style);
470
+ var cursor = document.createElement('div');
471
+ cursor.id = 'cdpilot-cursor';
472
+ cursor.style.display = 'none';
473
+ cursor.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M5.5 3.21V20.8l5.71-5.71h8.3L5.5 3.21z" fill="#22c55e" stroke="#15803d" stroke-width="1.2"/></svg>';
474
+ document.body.appendChild(cursor);
475
+ var ks = document.createElement('div');
476
+ ks.id = 'cdpilot-keystroke';
477
+ document.body.appendChild(ks);
478
+ window.__cdpilot_vfx = {
479
+ ripple: function(x, y) {
480
+ var el = document.createElement('div');
481
+ el.className = 'cdpilot-ripple';
482
+ el.style.left = x + 'px'; el.style.top = y + 'px';
483
+ document.body.appendChild(el);
484
+ var inner = document.createElement('div');
485
+ inner.className = 'cdpilot-ripple-inner';
486
+ inner.style.left = x + 'px'; inner.style.top = y + 'px';
487
+ document.body.appendChild(inner);
488
+ setTimeout(function() { el.remove(); inner.remove(); }, 700);
489
+ },
490
+ moveCursor: function(x, y) {
491
+ cursor.style.display = 'block';
492
+ cursor.style.left = (x - 3) + 'px';
493
+ cursor.style.top = (y - 2) + 'px';
494
+ },
495
+ hideCursor: function() { cursor.style.display = 'none'; },
496
+ keystroke: function(text) {
497
+ ks.textContent = text;
498
+ ks.style.opacity = '1';
499
+ clearTimeout(ks.__tid);
500
+ ks.__tid = setTimeout(function() { ks.style.opacity = '0'; }, 2000);
501
+ }
502
+ };
503
+ return 'vfx active';
504
+ })()
505
+ """
184
506
 
185
- GLOW_ACTIVATE_JS = "document.documentElement.dataset.cdpilotActive = 'true';"
186
- GLOW_DEACTIVATE_JS = """
187
- document.documentElement.removeAttribute('data-cdpilot-active');
188
- var _go = document.getElementById('cdpilot-glow-overlay');
189
- var _gs = document.getElementById('cdpilot-glow-style');
190
- if (_go) _go.remove();
191
- if (_gs) _gs.remove();
507
+ VISUAL_FEEDBACK_OFF = """
508
+ (function() {
509
+ delete window.__cdpilot_vfx;
510
+ ['cdpilot-vfx-style','cdpilot-cursor','cdpilot-keystroke'].forEach(function(id) {
511
+ var el = document.getElementById(id); if (el) el.remove();
512
+ });
513
+ document.querySelectorAll('.cdpilot-ripple,.cdpilot-ripple-inner').forEach(function(el) { el.remove(); });
514
+ return 'vfx off';
515
+ })()
516
+ """
517
+
518
+ # ─── Glow Auto-Timeout (fade out after 10s idle) ───
519
+
520
+ GLOW_TIMEOUT_JS = """
521
+ clearTimeout(window.__cdpilot_glow_timeout);
522
+ window.__cdpilot_glow_timeout = setTimeout(function() {
523
+ var o = document.getElementById('cdpilot-glow-overlay');
524
+ if (o) { o.style.opacity = '0'; setTimeout(function() { o.remove(); }, 1000); }
525
+ var s = document.getElementById('cdpilot-glow-style');
526
+ if (s) setTimeout(function() { s.remove(); }, 1100);
527
+ var t = document.getElementById('cdpilot-ai-toast');
528
+ if (t) t.remove();
529
+ if (window.__cdpilot_warn) {
530
+ document.removeEventListener('mousemove', window.__cdpilot_warn, true);
531
+ delete window.__cdpilot_warn;
532
+ }
533
+ ['cdpilot-vfx-style','cdpilot-cursor','cdpilot-keystroke'].forEach(function(id) {
534
+ var el = document.getElementById(id); if (el) el.remove();
535
+ });
536
+ delete window.__cdpilot_vfx;
537
+ }, 10000);
192
538
  """
193
539
 
540
+ # ─── Automation Indicator Wrapper ───
541
+
542
+ _glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
543
+
194
544
  async def _control_start(ws_url):
195
- """Enable visual indicator and input blocker at command start."""
545
+ """Enable glow, input blocker, and visual feedback at command start."""
196
546
  global _glow_script_id
197
547
  try:
198
- # Inject activation marker on every new page load
199
- r = await cdp_send(ws_url, [
200
- (901, "Page.addScriptToEvaluateOnNewDocument", {"source": GLOW_ACTIVATE_JS}),
201
- (902, "Runtime.evaluate", {"expression": GLOW_ACTIVATE_JS, "returnByValue": True}),
202
- (903, "Runtime.evaluate", {"expression": INPUT_BLOCKER_ON, "returnByValue": True}),
548
+ # Remove previous persistent script if exists (prevent accumulation)
549
+ cmds = []
550
+ if _glow_script_id:
551
+ cmds.append((900, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
552
+ _glow_script_id = None
553
+ persistent_source = GLOW_CSS + "\n" + VISUAL_FEEDBACK_JS
554
+ cmds.extend([
555
+ (901, "Page.addScriptToEvaluateOnNewDocument", {"source": persistent_source}),
556
+ (902, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
557
+ (903, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
558
+ (904, "Runtime.evaluate", {"expression": INPUT_BLOCKER_ON, "returnByValue": True}),
203
559
  ])
204
- # Save script identifier for cleanup
205
- if 901 in r and "identifier" in r.get(901, {}):
206
- _glow_script_id = r[901]["identifier"]
560
+ r = await cdp_send(ws_url, cmds)
561
+ resp_901 = r.get(901, {})
562
+ result = resp_901.get("result", {})
563
+ if isinstance(result, dict) and "identifier" in result:
564
+ _glow_script_id = result["identifier"]
207
565
  except Exception:
208
- pass # Silent fail if no connection
566
+ pass
209
567
 
210
568
  async def _control_end(ws_url):
211
- """Disable visual indicator and input blocker at command end."""
569
+ """Remove input blocker, keep glow alive.
570
+
571
+ In MCP session mode (CDPILOT_MCP_SESSION=1): glow stays permanently,
572
+ no timeout — mimics Claude's persistent orange glow behavior.
573
+ In CLI mode: glow fades after 10s idle (GLOW_TIMEOUT_JS).
574
+ """
212
575
  global _glow_script_id
213
576
  try:
214
577
  cmds = [
215
- (903, "Runtime.evaluate", {"expression": GLOW_DEACTIVATE_JS, "returnByValue": True}),
216
- (904, "Runtime.evaluate", {"expression": INPUT_BLOCKER_OFF, "returnByValue": True}),
578
+ (903, "Runtime.evaluate", {"expression": INPUT_BLOCKER_OFF, "returnByValue": True}),
579
+ # Re-inject glow+vfx on current page (may be new after navigation)
580
+ (906, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
581
+ (907, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
217
582
  ]
218
- # Remove the persistent new-document script
219
- if _glow_script_id:
220
- cmds.append((905, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
221
- _glow_script_id = None
583
+ if not IS_MCP_SESSION:
584
+ # CLI mode: start 10s auto-cleanup timeout
585
+ cmds.append((904, "Runtime.evaluate", {"expression": GLOW_TIMEOUT_JS, "returnByValue": True}))
586
+ # Don't remove persistent script — it auto-cleans via GLOW_TIMEOUT_JS (CLI)
587
+ # or stays forever (MCP session).
588
+ _glow_script_id = None
222
589
  await cdp_send(ws_url, cmds)
223
590
  except Exception:
224
- pass # Silent fail if no connection
591
+ pass
592
+
593
+ async def _vfx_ripple(ws_url, x, y):
594
+ """Show click ripple + move cursor at (x, y)."""
595
+ js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.moveCursor({x},{y});window.__cdpilot_vfx.ripple({x},{y});}}"
596
+ try:
597
+ await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
598
+ except Exception:
599
+ pass
600
+
601
+ async def _vfx_keystroke(ws_url, text):
602
+ """Show keystroke display."""
603
+ safe = json.dumps(text)
604
+ js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.keystroke({safe});}}"
605
+ try:
606
+ await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
607
+ except Exception:
608
+ pass
609
+
610
+ async def _vfx_move_cursor(ws_url, x, y):
611
+ """Move fake cursor to (x, y)."""
612
+ js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.moveCursor({x},{y});}}"
613
+ try:
614
+ await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
615
+ except Exception:
616
+ pass
225
617
 
226
618
  # ─── Connection Helpers ───
227
619
 
@@ -421,22 +813,32 @@ async def cdp_send(ws_url, commands, timeout=15):
421
813
  """Send multiple CDP commands and collect results."""
422
814
  import websockets
423
815
  results = {}
424
- async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
425
- for cmd_id, method, params in commands:
426
- await ws.send(json.dumps({"id": cmd_id, "method": method, "params": params or {}}))
816
+ try:
817
+ async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
818
+ for cmd_id, method, params in commands:
819
+ await ws.send(json.dumps({"id": cmd_id, "method": method, "params": params or {}}))
427
820
 
428
- pending = {c[0] for c in commands}
429
- start = time.time()
430
- while pending and (time.time() - start) < timeout:
431
- try:
432
- resp = await asyncio.wait_for(ws.recv(), timeout=2)
433
- data = json.loads(resp)
434
- if "id" in data and data["id"] in pending:
435
- pending.discard(data["id"])
436
- results[data["id"]] = data.get("result", data.get("error", {}))
437
- except asyncio.TimeoutError:
438
- continue
439
- return results
821
+ pending = {c[0] for c in commands}
822
+ start = time.time()
823
+ while pending and (time.time() - start) < timeout:
824
+ try:
825
+ resp = await asyncio.wait_for(ws.recv(), timeout=2)
826
+ data = json.loads(resp)
827
+ if "id" in data and data["id"] in pending:
828
+ pending.discard(data["id"])
829
+ results[data["id"]] = data.get("result", data.get("error", {}))
830
+ except asyncio.TimeoutError:
831
+ continue
832
+ return results
833
+ except ConnectionRefusedError:
834
+ print("Browser is not running. Run 'cdpilot launch' first.", file=sys.stderr)
835
+ sys.exit(1)
836
+ except Exception as e:
837
+ err = str(e)
838
+ if "websocket" in err.lower() or "connect" in err.lower() or "ws://" in err.lower():
839
+ print(f"Browser is not running or CDP port {CDP_PORT} is unreachable. Run 'cdpilot launch' first.", file=sys.stderr)
840
+ sys.exit(1)
841
+ raise
440
842
 
441
843
 
442
844
  async def navigate_collect(ws_url, url, network=False, console=False, glow=True):
@@ -495,11 +897,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
495
897
  if loaded:
496
898
  break
497
899
 
498
- # Inject visual indicator (sets data-cdpilot-active attribute)
900
+ # Inject visual indicator (glow overlay + visual feedback)
499
901
  if glow:
500
902
  await ws.send(json.dumps({
501
903
  "id": 200, "method": "Runtime.evaluate",
502
- "params": {"expression": GLOW_ACTIVATE_JS, "returnByValue": True}
904
+ "params": {"expression": GLOW_CSS, "returnByValue": True}
905
+ }))
906
+ sid += 1
907
+ await ws.send(json.dumps({
908
+ "id": sid, "method": "Runtime.evaluate",
909
+ "params": {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}
503
910
  }))
504
911
 
505
912
  # Inject dev extension content scripts via the existing WS connection
@@ -556,6 +963,49 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
556
963
 
557
964
  # ─── Helper Functions ───
558
965
 
966
+ def _find_browser():
967
+ """İşletim sistemine göre tarayıcı ikili dosyasını otomatik olarak bulur."""
968
+ for b in ["brave-browser", "google-chrome", "chromium-browser", "chromium"]:
969
+ found = shutil.which(b)
970
+ if found:
971
+ return found
972
+
973
+ system = platform.system()
974
+ if system == "Darwin":
975
+ paths = [
976
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
977
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
978
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
979
+ ]
980
+ elif system == "Linux":
981
+ paths = [
982
+ "/usr/bin/brave-browser",
983
+ "/usr/bin/google-chrome",
984
+ "/usr/bin/chromium-browser",
985
+ "/usr/bin/chromium",
986
+ "/snap/bin/chromium",
987
+ ]
988
+ elif system == "Windows":
989
+ paths = [
990
+ os.path.expandvars(r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe"),
991
+ os.path.expandvars(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
992
+ os.path.expandvars(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
993
+ os.path.expandvars(r"C:\Program Files\Chromium\Application\chromium.exe"),
994
+ ]
995
+ else:
996
+ return None
997
+
998
+ for path in paths:
999
+ if os.path.exists(path):
1000
+ return path
1001
+ return None
1002
+
1003
+
1004
+ def _is_port_in_use(port):
1005
+ """Check if a port is actively in use."""
1006
+ return not _is_port_free(port)
1007
+
1008
+
559
1009
  def get_dev_extensions():
560
1010
  """Read registered dev mode extension paths."""
561
1011
  if os.path.exists(DEV_EXTENSIONS_FILE):
@@ -741,17 +1191,34 @@ def get_headless_config():
741
1191
 
742
1192
  def cmd_launch():
743
1193
  """Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
1194
+ global CHROME_BIN, CDP_PORT, CDP_BASE
744
1195
  if cdp_get('/json/version'):
745
- print(f'Browser already running on port {CDP_PORT}.')
1196
+ proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
1197
+ print(f'Browser already running on port {CDP_PORT}{proj_label}.')
746
1198
  return
1199
+ if _is_port_in_use(CDP_PORT):
1200
+ if PROJECT_ID:
1201
+ # Auto-allocate a new port
1202
+ new_port = _allocate_port(PROJECT_ID)
1203
+ print(f'Port {CDP_PORT} busy, using {new_port}.')
1204
+ CDP_PORT = new_port
1205
+ CDP_BASE = f"http://127.0.0.1:{CDP_PORT}"
1206
+ else:
1207
+ print(f'Error: Port {CDP_PORT} is in use. Set CDP_PORT to a different port.', file=sys.stderr)
1208
+ sys.exit(1)
747
1209
 
748
1210
  if not CHROME_BIN:
749
- print('Error: CHROME_BIN is not set. Export CHROME_BIN=/path/to/browser.', file=sys.stderr)
750
- sys.exit(1)
1211
+ bin_path = _find_browser()
1212
+ if not bin_path:
1213
+ print('No supported browser found. Install Brave, Chrome, or Chromium and ensure it is in PATH or set CHROME_BIN.', file=sys.stderr)
1214
+ sys.exit(1)
1215
+ CHROME_BIN = bin_path
1216
+ print(f'Browser found: {bin_path}')
751
1217
 
752
1218
  os.makedirs(PROFILE_DIR, exist_ok=True)
753
1219
 
754
- print(f'Launching browser (isolated session, port {CDP_PORT})...')
1220
+ proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
1221
+ print(f'Launching browser (isolated session, port {CDP_PORT}){proj_label}...')
755
1222
 
756
1223
  chrome_args = [
757
1224
  CHROME_BIN,
@@ -816,12 +1283,15 @@ def cmd_launch():
816
1283
  chrome_args.append('--headless=new')
817
1284
  print(' Mode: headless')
818
1285
 
819
- subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
1286
+ proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
820
1287
 
821
1288
  for _ in range(20):
822
1289
  time.sleep(0.5)
823
1290
  if cdp_get('/json/version'):
824
- print(f'CDP ready! (port {CDP_PORT})')
1291
+ if PROJECT_ID:
1292
+ _register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
1293
+ proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
1294
+ print(f'CDP ready! (port {CDP_PORT}){proj_label}')
825
1295
  return
826
1296
  print('Failed to start CDP (timeout).', file=sys.stderr)
827
1297
  sys.exit(1)
@@ -850,31 +1320,164 @@ async def cmd_go(url):
850
1320
  async def cmd_content():
851
1321
  ws, _ = get_page_ws()
852
1322
  r = await cdp_send(ws, [(1, "Runtime.evaluate", {
853
- "expression": "document.body.innerText.substring(0, 10000)",
1323
+ "expression": "document.body.innerText.substring(0, 1048576)",
854
1324
  "returnByValue": True,
855
1325
  })])
856
- print(r.get(1, {}).get("result", {}).get("value", "(empty)"))
1326
+ content = r.get(1, {}).get("result", {}).get("value", "(empty)")
1327
+ print(content)
1328
+ if len(content) >= 1048576:
1329
+ print("[Output truncated at 1MB]")
857
1330
 
858
1331
 
859
1332
  async def cmd_html():
860
1333
  ws, _ = get_page_ws()
861
1334
  r = await cdp_send(ws, [(1, "Runtime.evaluate", {
862
- "expression": "document.documentElement.outerHTML.substring(0, 30000)",
1335
+ "expression": "document.documentElement.outerHTML.substring(0, 1048576)",
863
1336
  "returnByValue": True,
864
1337
  })])
865
- print(r.get(1, {}).get("result", {}).get("value", "(empty)"))
1338
+ html_content = r.get(1, {}).get("result", {}).get("value", "(empty)")
1339
+ print(html_content)
1340
+ if len(html_content) >= 1048576:
1341
+ print("[Output truncated at 1MB]")
866
1342
 
867
1343
 
868
- async def cmd_shot(output=None):
1344
+ async def cmd_shot(output=None, quality=None, element=None, fmt=None):
1345
+ """Take screenshot. Supports --quality, --element, --format for token savings.
1346
+
1347
+ Args:
1348
+ output: File path (auto-detects format from extension)
1349
+ quality: JPEG quality 1-100 (only for jpeg format, saves ~5-7x tokens)
1350
+ element: CSS selector to capture only that element (crop)
1351
+ fmt: Force format: 'png', 'jpeg', or 'webp'
1352
+ """
869
1353
  if not output:
870
1354
  output = f"{SCREENSHOT_DIR}/screenshot.png"
871
1355
  ws, _ = get_page_ws()
872
- r = await cdp_send(ws, [(1, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": True})])
873
- b64 = r.get(1, {}).get("data", "")
1356
+
1357
+ # Auto-detect format from extension
1358
+ if fmt is None:
1359
+ ext = os.path.splitext(output)[1].lower()
1360
+ fmt = {"jpg": "jpeg", ".jpg": "jpeg", ".jpeg": "jpeg", ".webp": "webp"}.get(ext, "png")
1361
+
1362
+ params = {"format": fmt}
1363
+ if fmt == "jpeg" and quality:
1364
+ params["quality"] = max(1, min(100, int(quality)))
1365
+ elif fmt == "jpeg" and quality is None:
1366
+ params["quality"] = 80 # sensible default
1367
+
1368
+ # Element-level crop: get bounding rect, use clip
1369
+ if element:
1370
+ safe_sel = json.dumps(element)
1371
+ js = f"""
1372
+ (function() {{
1373
+ var el = document.querySelector({safe_sel});
1374
+ if (!el) return null;
1375
+ el.scrollIntoView({{block: 'center'}});
1376
+ var r = el.getBoundingClientRect();
1377
+ return JSON.stringify({{x: r.x, y: r.y, width: r.width, height: r.height}});
1378
+ }})()
1379
+ """
1380
+ cr = await cdp_send(ws, [(2, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1381
+ rect_str = cr.get(2, {}).get("result", {}).get("value")
1382
+ if rect_str:
1383
+ rect = json.loads(rect_str)
1384
+ params["clip"] = {
1385
+ "x": rect["x"], "y": rect["y"],
1386
+ "width": rect["width"], "height": rect["height"],
1387
+ "scale": 1
1388
+ }
1389
+ else:
1390
+ print(f"Element not found: {element}, taking full page", file=sys.stderr)
1391
+
1392
+ r = await cdp_send(ws, [(1, "Page.captureScreenshot", params)])
1393
+ b64 = r.get(1, {}).get("result", {}).get("data", "") or r.get(1, {}).get("data", "")
1394
+ if b64:
1395
+ data = base64.b64decode(b64)
1396
+ with open(output, "wb") as f:
1397
+ f.write(data)
1398
+ size_kb = len(data) / 1024
1399
+ print(f"{output} ({size_kb:.1f}KB)")
1400
+ else:
1401
+ print("Screenshot failed", file=sys.stderr)
1402
+
1403
+
1404
+ async def cmd_shot_annotated(output=None):
1405
+ """Etkileşimli elementler üzerine @N badge eklenmiş annotated screenshot al."""
1406
+ if not output:
1407
+ output = f"{SCREENSHOT_DIR}/screenshot-annotated.png"
1408
+ ws_url, _ = get_page_ws()
1409
+
1410
+ # A11y tree'den etkileşimli node'ları topla
1411
+ await cdp_send(ws_url, [
1412
+ (0, "Accessibility.enable", {}),
1413
+ (9, "DOM.enable", {}),
1414
+ ])
1415
+ res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
1416
+ nodes = res.get(1, {}).get("nodes", [])
1417
+
1418
+ interactive_roles = {
1419
+ "button", "link", "textbox", "textField", "combobox", "comboBox",
1420
+ "checkbox", "radio", "menuitem", "menuItem", "searchbox", "searchBox",
1421
+ "spinbutton", "spinButton", "switch", "tab", "slider",
1422
+ }
1423
+ targets = [
1424
+ n for n in nodes
1425
+ if not n.get("ignored")
1426
+ and n.get("role", {}).get("value", "") in interactive_roles
1427
+ and n.get("backendDOMNodeId")
1428
+ ]
1429
+
1430
+ # Her node için ekran koordinatlarını al
1431
+ badge_count = 0
1432
+ inject_parts = []
1433
+ for idx, node in enumerate(targets, start=1):
1434
+ backend_id = node["backendDOMNodeId"]
1435
+ res_b = await cdp_send(ws_url, [(11, "DOM.getBoxModel", {"backendNodeId": backend_id})])
1436
+ model = res_b.get(11, {}).get("model")
1437
+ if not model:
1438
+ continue
1439
+ content = model.get("content", model.get("border", []))
1440
+ if len(content) < 8:
1441
+ continue
1442
+ left = int(content[0])
1443
+ top = int(content[1])
1444
+ width = int(content[2] - content[0])
1445
+ height = int(content[5] - content[1])
1446
+ if width == 0 or height == 0:
1447
+ continue
1448
+ label = json.dumps(f"@{idx}")
1449
+ inject_parts.append(
1450
+ f"(function(){{"
1451
+ f"var b=document.createElement('span');"
1452
+ f"b.textContent={label};"
1453
+ f"b.setAttribute('data-cdpilot-badge','1');"
1454
+ f"b.style.cssText='position:fixed;left:{left}px;top:{top}px;"
1455
+ f"background:#22c55e;color:#fff;font-size:11px;font-weight:bold;"
1456
+ f"padding:1px 4px;border-radius:3px;z-index:99999;"
1457
+ f"pointer-events:none;line-height:1.4;';"
1458
+ f"document.body.appendChild(b);"
1459
+ f"}})();"
1460
+ )
1461
+ badge_count += 1
1462
+
1463
+ # Badge'leri inject et
1464
+ if inject_parts:
1465
+ inject_js = "\n".join(inject_parts)
1466
+ await cdp_send(ws_url, [(20, "Runtime.evaluate", {"expression": inject_js})])
1467
+
1468
+ # Screenshot al
1469
+ r = await cdp_send(ws_url, [(21, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})])
1470
+ b64 = r.get(21, {}).get("data", "")
1471
+
1472
+ # Badge'leri temizle
1473
+ await cdp_send(ws_url, [(22, "Runtime.evaluate", {
1474
+ "expression": "document.querySelectorAll('[data-cdpilot-badge]').forEach(function(e){e.remove();})"
1475
+ })])
1476
+
874
1477
  if b64:
875
1478
  with open(output, "wb") as f:
876
1479
  f.write(base64.b64decode(b64))
877
- print(f"{output}")
1480
+ print(f"{output} ({badge_count} badge)")
878
1481
  else:
879
1482
  print("Screenshot failed", file=sys.stderr)
880
1483
 
@@ -904,35 +1507,54 @@ async def cmd_eval(js_code):
904
1507
  async def cmd_click(selector):
905
1508
  ws, _ = get_page_ws()
906
1509
  safe_sel = json.dumps(selector)
907
- js = f"""(function() {{
908
- const el = document.querySelector({safe_sel});
909
- if (!el) return 'Not found: ' + {safe_sel};
1510
+ js = WAIT_AND_QUERY_JS + f"""
1511
+ (function() {{
1512
+ return window.__cdpilot_waitFor({safe_sel}, 5000).then(function(el) {{
1513
+ if (!el) return 'Timeout waiting for: ' + {safe_sel};
910
1514
  el.scrollIntoView({{behavior:'smooth', block:'center'}});
1515
+ if (window.__cdpilot_vfx) {{
1516
+ var r = el.getBoundingClientRect();
1517
+ var cx = Math.round(r.left + r.width/2), cy = Math.round(r.top + r.height/2);
1518
+ window.__cdpilot_vfx.moveCursor(cx, cy);
1519
+ window.__cdpilot_vfx.ripple(cx, cy);
1520
+ }}
911
1521
  el.click();
912
1522
  return 'Clicked: ' + el.tagName + ' ' + (el.textContent || '').substring(0, 60).trim();
913
- }})()"""
914
- r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1523
+ }});
1524
+ }})()"""
1525
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
915
1526
  print(r.get(1, {}).get("result", {}).get("value", "?"))
916
1527
 
917
1528
 
918
1529
  async def cmd_fill(selector, value):
919
- """Fill an input field (React/Vue compatible)."""
1530
+ """Fill an input field with auto-wait (React/Vue compatible)."""
920
1531
  ws, _ = get_page_ws()
921
1532
  safe_sel = json.dumps(selector)
922
1533
  safe_value = json.dumps(value)
923
- js = f"""(function() {{
924
- const el = document.querySelector({safe_sel});
925
- if (!el) return 'Not found: ' + {safe_sel};
1534
+ js = WAIT_AND_QUERY_JS + f"""
1535
+ (function() {{
1536
+ return window.__cdpilot_waitFor({safe_sel}, 5000).then(function(el) {{
1537
+ if (!el) return 'Timeout waiting for: ' + {safe_sel};
926
1538
  el.focus();
927
- const nativeSet = Object.getOwnPropertyDescriptor(
1539
+ if (window.__cdpilot_vfx) {{
1540
+ var r = el.getBoundingClientRect();
1541
+ window.__cdpilot_vfx.moveCursor(Math.round(r.left + r.width/2), Math.round(r.top + r.height/2));
1542
+ window.__cdpilot_vfx.keystroke('\\u2328 ' + {safe_value}.substring(0, 30));
1543
+ }}
1544
+ var nativeSet = Object.getOwnPropertyDescriptor(
928
1545
  window.HTMLInputElement.prototype, 'value'
929
- ).set;
930
- nativeSet.call(el, {safe_value});
1546
+ );
1547
+ if (nativeSet && nativeSet.set) {{
1548
+ nativeSet.set.call(el, {safe_value});
1549
+ }} else {{
1550
+ el.value = {safe_value};
1551
+ }}
931
1552
  el.dispatchEvent(new Event('input', {{bubbles: true}}));
932
1553
  el.dispatchEvent(new Event('change', {{bubbles: true}}));
933
1554
  return 'Filled: ' + el.tagName + ' = ' + el.value.substring(0, 50);
934
- }})()"""
935
- r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1555
+ }});
1556
+ }})()"""
1557
+ r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
936
1558
  print(r.get(1, {}).get("result", {}).get("value", "?"))
937
1559
 
938
1560
 
@@ -968,6 +1590,55 @@ async def cmd_wait(selector, timeout=5):
968
1590
  print(r.get(1, {}).get("result", {}).get("value", "?"))
969
1591
 
970
1592
 
1593
+ async def cmd_batch():
1594
+ """stdin'den JSON komut dizisi oku, sırayla çalıştır, sonuçları JSON olarak yaz.
1595
+
1596
+ Kullanım:
1597
+ echo '[{"cmd":"go","args":["https://example.com"]},{"cmd":"shot","args":["/tmp/out.png"]}]' | cdpilot batch
1598
+ """
1599
+ try:
1600
+ raw = sys.stdin.read()
1601
+ data = json.loads(raw)
1602
+ if not isinstance(data, list):
1603
+ print(json.dumps({"error": "Input must be a JSON array"}), file=sys.stderr)
1604
+ sys.exit(1)
1605
+ except json.JSONDecodeError as exc:
1606
+ print(json.dumps({"error": f"JSON parse error: {exc}"}), file=sys.stderr)
1607
+ sys.exit(1)
1608
+
1609
+ results = []
1610
+ for item in data:
1611
+ cmd_name = item.get("cmd", "")
1612
+ cmd_args = item.get("args", [])
1613
+ try:
1614
+ if cmd_name == "go":
1615
+ await cmd_go(cmd_args[0] if cmd_args else "")
1616
+ elif cmd_name == "click":
1617
+ await cmd_click(cmd_args[0] if cmd_args else "")
1618
+ elif cmd_name in ("fill", "type"):
1619
+ await cmd_fill(cmd_args[0] if cmd_args else "", cmd_args[1] if len(cmd_args) > 1 else "")
1620
+ elif cmd_name == "shot":
1621
+ await cmd_shot(cmd_args[0] if cmd_args else None)
1622
+ elif cmd_name == "shot-annotated":
1623
+ await cmd_shot_annotated(cmd_args[0] if cmd_args else None)
1624
+ elif cmd_name == "wait":
1625
+ await cmd_wait(cmd_args[0] if cmd_args else "body", int(cmd_args[1]) if len(cmd_args) > 1 else 5)
1626
+ elif cmd_name == "eval":
1627
+ await cmd_eval(" ".join(cmd_args) if cmd_args else "")
1628
+ elif cmd_name == "submit":
1629
+ await cmd_submit(cmd_args[0] if cmd_args else "form")
1630
+ else:
1631
+ results.append({"cmd": cmd_name, "status": "error", "error": f"Unsupported command: {cmd_name}"})
1632
+ continue
1633
+ results.append({"cmd": cmd_name, "status": "ok"})
1634
+ except SystemExit:
1635
+ results.append({"cmd": cmd_name, "status": "error", "error": "Command exited with error"})
1636
+ except Exception as exc:
1637
+ results.append({"cmd": cmd_name, "status": "error", "error": str(exc)})
1638
+
1639
+ print(json.dumps(results, indent=2, ensure_ascii=False))
1640
+
1641
+
971
1642
  async def cmd_network(url=None):
972
1643
  ws, page = get_page_ws()
973
1644
  if url is None:
@@ -1080,8 +1751,16 @@ async def cmd_emulate(device):
1080
1751
 
1081
1752
  async def cmd_glow(state="on"):
1082
1753
  ws, page = get_page_ws()
1083
- js = GLOW_ACTIVATE_JS if state == "on" else GLOW_DEACTIVATE_JS
1084
- r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
1754
+ if state == "on":
1755
+ await cdp_send(ws, [
1756
+ (1, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
1757
+ (2, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
1758
+ ])
1759
+ else:
1760
+ await cdp_send(ws, [
1761
+ (1, "Runtime.evaluate", {"expression": GLOW_OFF_CSS, "returnByValue": True}),
1762
+ (2, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_OFF, "returnByValue": True}),
1763
+ ])
1085
1764
  print(f"Visual indicator {'on' if state == 'on' else 'off'}")
1086
1765
 
1087
1766
 
@@ -1412,7 +2091,8 @@ def cmd_ext_remove(ext_id):
1412
2091
  async def cmd_new_tab(url='about:blank'):
1413
2092
  """Open a new tab."""
1414
2093
  import urllib.parse
1415
- data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=":/?#[]@!$&\'()*+,;=")}')
2094
+ safe_chars = ":/?#[]@!$&'()*+,;="
2095
+ data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
1416
2096
  if data:
1417
2097
  print(f'New tab opened: {data.get("url", url)}')
1418
2098
  print(f' ID: {data.get("id", "?")}')
@@ -1617,29 +2297,160 @@ def cmd_headless(state=None):
1617
2297
  print('Restart browser (stop → launch).')
1618
2298
 
1619
2299
 
2300
+ def _stop_browser_on_port(port):
2301
+ """Stop the browser process listening on the given port."""
2302
+ import signal
2303
+ try:
2304
+ result = subprocess.run(
2305
+ ["lsof", "-ti", f":{port}"],
2306
+ capture_output=True, text=True
2307
+ )
2308
+ pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
2309
+ if pids:
2310
+ for pid in pids:
2311
+ os.kill(int(pid), signal.SIGTERM)
2312
+ return True
2313
+ else:
2314
+ subprocess.run(
2315
+ ["pkill", "-f", f"remote-debugging-port={port}"],
2316
+ capture_output=True, text=True
2317
+ )
2318
+ return True
2319
+ except Exception:
2320
+ return False
2321
+
2322
+
1620
2323
  def cmd_stop():
1621
2324
  """Stop the browser instance managed by cdpilot."""
2325
+ if platform.system() == "Windows":
2326
+ browser_procs = ["brave.exe", "chrome.exe", "chromium.exe"]
2327
+ stopped_any = False
2328
+ for proc in browser_procs:
2329
+ try:
2330
+ result = subprocess.run(
2331
+ ["taskkill", "/F", "/IM", proc],
2332
+ capture_output=True, text=True
2333
+ )
2334
+ if result.returncode == 0:
2335
+ print(f" {proc} terminated")
2336
+ stopped_any = True
2337
+ except Exception:
2338
+ pass
2339
+ if stopped_any:
2340
+ print(f"Browser stopped (port {CDP_PORT}).")
2341
+ else:
2342
+ print(f"No browser process found (port {CDP_PORT}).", file=sys.stderr)
2343
+ return
2344
+
1622
2345
  import signal
1623
2346
  try:
1624
2347
  result = subprocess.run(
1625
2348
  ["lsof", "-ti", f":{CDP_PORT}"],
1626
2349
  capture_output=True, text=True
1627
2350
  )
1628
- pids = result.stdout.strip().split("\n")
1629
- for pid in pids:
1630
- if pid.strip():
1631
- os.kill(int(pid.strip()), signal.SIGTERM)
1632
- print(f" PID {pid.strip()} terminated")
1633
- print(f"Browser stopped (port {CDP_PORT}).")
2351
+ pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
2352
+ if pids:
2353
+ for pid in pids:
2354
+ os.kill(int(pid), signal.SIGTERM)
2355
+ print(f" PID {pid} terminated")
2356
+ print(f"Browser stopped (port {CDP_PORT}).")
2357
+ else:
2358
+ # lsof bulamazsa pkill ile dene
2359
+ subprocess.run(
2360
+ ["pkill", "-f", f"remote-debugging-port={CDP_PORT}"],
2361
+ capture_output=True, text=True
2362
+ )
2363
+ print(f"Browser stopped (port {CDP_PORT}).")
1634
2364
  except Exception as e:
1635
2365
  print(f"Stop error: {e}", file=sys.stderr)
1636
2366
 
2367
+ # Update registry
2368
+ if PROJECT_ID:
2369
+ registry = _load_registry()
2370
+ if PROJECT_ID in registry:
2371
+ registry[PROJECT_ID]["status"] = "stopped"
2372
+ registry[PROJECT_ID]["pid"] = None
2373
+ _save_registry(registry)
2374
+
1637
2375
 
1638
2376
  def cmd_version():
1639
2377
  """Show cdpilot version."""
1640
2378
  print(f"cdpilot v{__version__}")
1641
2379
 
1642
2380
 
2381
+ def cmd_projects():
2382
+ """List all registered cdpilot project instances."""
2383
+ registry = _cleanup_registry()
2384
+ if not registry:
2385
+ print("No registered projects.")
2386
+ return
2387
+
2388
+ current = PROJECT_ID
2389
+ print(f"{'Project':<28} {'Port':<7} {'Status':<10} {'CWD'}")
2390
+ print("\u2500" * 90)
2391
+
2392
+ for pid, info in sorted(registry.items(),
2393
+ key=lambda x: x[1].get("last_used", ""), reverse=True):
2394
+ port = info.get("port", "?")
2395
+ status = info.get("status", "?")
2396
+ cwd = info.get("cwd", "?")
2397
+ # Live check
2398
+ if status == "running" and _is_port_free(port):
2399
+ status = "stopped"
2400
+ icon = "\U0001f7e2" if status == "running" else "\u26ab"
2401
+ marker = " \u2190 current" if pid == current else ""
2402
+ if len(cwd) > 45:
2403
+ cwd = "..." + cwd[-42:]
2404
+ print(f" {pid:<26} {port:<7} {icon} {status:<8} {cwd}{marker}")
2405
+
2406
+ print(f"\nTotal: {len(registry)} project(s)")
2407
+
2408
+
2409
+ def cmd_project_stop(name):
2410
+ """Stop a specific project's browser instance."""
2411
+ registry = _load_registry()
2412
+ target_id = None
2413
+ for pid, info in registry.items():
2414
+ if name in pid or name in info.get("cwd", ""):
2415
+ target_id = pid
2416
+ break
2417
+
2418
+ if not target_id:
2419
+ print(f"Project not found: {name}", file=sys.stderr)
2420
+ sys.exit(1)
2421
+
2422
+ info = registry[target_id]
2423
+ port = info.get("port")
2424
+ if port and not _is_port_free(port):
2425
+ _stop_browser_on_port(port)
2426
+ print(f"Stopped: {target_id} (port {port})")
2427
+ else:
2428
+ print(f"Project already stopped: {target_id}")
2429
+
2430
+ info["status"] = "stopped"
2431
+ info["pid"] = None
2432
+ _save_registry(registry)
2433
+
2434
+
2435
+ def cmd_stop_all():
2436
+ """Stop all active cdpilot browser instances."""
2437
+ registry = _cleanup_registry()
2438
+ stopped = 0
2439
+ for pid, info in registry.items():
2440
+ port = info.get("port")
2441
+ if port and info.get("status") == "running" and not _is_port_free(port):
2442
+ _stop_browser_on_port(port)
2443
+ info["status"] = "stopped"
2444
+ info["pid"] = None
2445
+ stopped += 1
2446
+ print(f" Stopped: {pid} (port {port})")
2447
+ _save_registry(registry)
2448
+ if stopped:
2449
+ print(f"\n{stopped} instance(s) stopped.")
2450
+ else:
2451
+ print("No active instances.")
2452
+
2453
+
1643
2454
  # ─── New CDP Commands ───
1644
2455
 
1645
2456
  async def _get_element_center(ws_url, selector):
@@ -1858,12 +2669,604 @@ async def cmd_a11y(subcmd=""):
1858
2669
  sys.exit(1)
1859
2670
 
1860
2671
 
2672
+ async def cmd_describe():
2673
+ """Full page description for AI agents — structured data + screenshot fallback.
2674
+
2675
+ Outputs URL/title, accessibility snapshot, screenshot path, and text content.
2676
+ AI agents can use the a11y snapshot for cheap navigation; fall back to the
2677
+ screenshot with a vision model when the snapshot is insufficient (canvas, WebGL).
2678
+ """
2679
+ global _A11Y_REF_MAP
2680
+ _A11Y_REF_MAP = {}
2681
+ ws_url, page = get_page_ws()
2682
+
2683
+ print("=== Page Description ===")
2684
+ print(f"URL: {page.get('url', 'N/A')}")
2685
+ print(f"Title: {page.get('title', 'N/A')}")
2686
+ print()
2687
+
2688
+ # ── Accessibility Snapshot ──────────────────────────────────────────────
2689
+ print("=== Accessibility Snapshot ===")
2690
+
2691
+ ROLE_NORMALIZE = {
2692
+ "textField": "textbox",
2693
+ "comboBox": "combobox",
2694
+ "checkBox": "checkbox",
2695
+ "radioButton": "radio",
2696
+ }
2697
+
2698
+ SKIP_ROLES = {
2699
+ "none", "presentation", "generic", "LineBreak", "InlineTextBox",
2700
+ "ignored", "unknown"
2701
+ }
2702
+
2703
+ INTERACTIVE_ROLES = {
2704
+ "button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
2705
+ "menuitem", "menuitemcheckbox", "menuitemradio", "option", "spinbutton",
2706
+ "slider", "switch", "tab", "treeitem"
2707
+ }
2708
+
2709
+ STRUCTURAL_ROLES = {
2710
+ "heading", "img", "navigation", "menu", "list", "listitem", "table",
2711
+ "grid", "row", "cell", "columnheader", "rowheader", "dialog", "alert",
2712
+ "main", "banner", "contentinfo", "region", "figure", "article", "section"
2713
+ }
2714
+
2715
+ def _get_prop(node, prop_name):
2716
+ for p in node.get("properties", []):
2717
+ if p.get("name") == prop_name:
2718
+ return p.get("value", {}).get("value", "")
2719
+ return ""
2720
+
2721
+ await cdp_send(ws_url, [(0, "Accessibility.enable", {})])
2722
+ res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
2723
+ nodes = res.get(1, {}).get("nodes", [])
2724
+
2725
+ if not nodes:
2726
+ print("Could not get accessibility tree.", file=sys.stderr)
2727
+
2728
+ ref_count = 0
2729
+ interactive_count = 0
2730
+ output_lines = []
2731
+
2732
+ for node in nodes:
2733
+ backend_node_id = node.get("backendDOMNodeId") or node.get("backendNodeId")
2734
+ role = node.get("role", {}).get("value")
2735
+ name = node.get("name", {}).get("value")
2736
+ description = node.get("description", {}).get("value")
2737
+ ignored = node.get("ignored", False)
2738
+
2739
+ if ignored:
2740
+ continue
2741
+ if not role or role in SKIP_ROLES:
2742
+ continue
2743
+ if not backend_node_id:
2744
+ continue
2745
+ if not name and not description and role in {"staticText", "text", "paragraph"}:
2746
+ continue
2747
+
2748
+ normalized = ROLE_NORMALIZE.get(role, role)
2749
+
2750
+ if normalized == "heading":
2751
+ level = _get_prop(node, "level")
2752
+ if level:
2753
+ normalized = f"heading/{level}"
2754
+
2755
+ base_role = normalized.split("/")[0]
2756
+ is_interactive = base_role in INTERACTIVE_ROLES
2757
+ is_structural = base_role in STRUCTURAL_ROLES
2758
+
2759
+ if not (is_interactive or (is_structural and (name or description))):
2760
+ continue
2761
+
2762
+ ref_count += 1
2763
+ _A11Y_REF_MAP[ref_count] = backend_node_id
2764
+
2765
+ if is_interactive:
2766
+ interactive_count += 1
2767
+
2768
+ display_name = name or description or ""
2769
+ attrs = []
2770
+
2771
+ if base_role == "link":
2772
+ href = _get_prop(node, "url")
2773
+ if href:
2774
+ attrs.append(f"href={href}")
2775
+
2776
+ if _get_prop(node, "disabled") == "true":
2777
+ attrs.append("disabled")
2778
+
2779
+ if _get_prop(node, "required") == "true":
2780
+ attrs.append("required")
2781
+
2782
+ if base_role in {"textbox", "combobox"}:
2783
+ val = _get_prop(node, "value")
2784
+ if val:
2785
+ attrs.append(f"value={val!r}")
2786
+ ph = _get_prop(node, "placeholder")
2787
+ if ph:
2788
+ attrs.append(f"placeholder={ph!r}")
2789
+
2790
+ if base_role in {"checkbox", "radio"}:
2791
+ if _get_prop(node, "checked") == "true":
2792
+ attrs.append("checked")
2793
+
2794
+ if _get_prop(node, "expanded") == "true":
2795
+ attrs.append("expanded")
2796
+
2797
+ attr_str = (" " + " ".join(attrs)) if attrs else ""
2798
+ output_lines.append(f"@{ref_count} [{normalized}] \"{display_name}\"{attr_str}")
2799
+
2800
+ _save_a11y_refs(_A11Y_REF_MAP)
2801
+
2802
+ for line in output_lines:
2803
+ print(line)
2804
+ print(f"\n[{interactive_count} interactive, {ref_count} total shown]")
2805
+ print()
2806
+
2807
+ # ── Screenshot ─────────────────────────────────────────────────────────
2808
+ print("=== Screenshot ===")
2809
+ ts = int(time.time())
2810
+ shot_path = f"{SCREENSHOT_DIR}/cdpilot-describe-{ts}.png"
2811
+ shot_res = await cdp_send(ws_url, [(2, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})])
2812
+ shot_data = shot_res.get(2, {}).get("data")
2813
+ if shot_data:
2814
+ with open(shot_path, "wb") as f:
2815
+ f.write(base64.b64decode(shot_data))
2816
+ print(f"Saved: {shot_path}")
2817
+ else:
2818
+ print("Could not capture screenshot.", file=sys.stderr)
2819
+ print()
2820
+
2821
+ # ── Text Content ────────────────────────────────────────────────────────
2822
+ print("=== Page Content (first 2000 chars) ===")
2823
+ eval_res = await cdp_send(ws_url, [(3, "Runtime.evaluate", {
2824
+ "expression": "document.body ? document.body.innerText : ''",
2825
+ "returnByValue": True
2826
+ })])
2827
+ page_text = eval_res.get(3, {}).get("result", {}).get("value", "")
2828
+ print(page_text[:2000])
2829
+
2830
+
2831
+ # ─── Testing Commands ───
2832
+
2833
+ async def cmd_assert(selector, expected_text=None, check_visible=True):
2834
+ """Assert element exists, optionally check text content and visibility."""
2835
+ ws_url, _ = get_page_ws()
2836
+ visible_check = ""
2837
+ if check_visible:
2838
+ visible_check = """
2839
+ var rect = el.getBoundingClientRect();
2840
+ var style = window.getComputedStyle(el);
2841
+ if (style.display === 'none' || style.visibility === 'hidden' ||
2842
+ style.opacity === '0' || (rect.width === 0 && rect.height === 0)) {
2843
+ return 'FAIL: Element found but not visible: ' + sel;
2844
+ }"""
2845
+ text_check = ""
2846
+ if expected_text:
2847
+ safe_text = json.dumps(expected_text)
2848
+ text_check = f"""
2849
+ var actual = el.textContent || el.value || '';
2850
+ if (actual.indexOf({safe_text}) === -1) {{
2851
+ return 'FAIL: Expected text ' + {safe_text} + ' not found in: ' + actual.substring(0, 100);
2852
+ }}"""
2853
+ safe_sel = json.dumps(selector)
2854
+ js = f"""
2855
+ (function() {{
2856
+ var sel = {safe_sel};
2857
+ var el = document.querySelector(sel);
2858
+ if (!el) return 'FAIL: Element not found: ' + sel;
2859
+ {visible_check}
2860
+ {text_check}
2861
+ var tag = el.tagName.toLowerCase();
2862
+ var txt = (el.textContent || '').substring(0, 60).trim();
2863
+ return 'PASS: ' + tag + (txt ? ' "' + txt + '"' : '') + ' (' + sel + ')';
2864
+ }})()
2865
+ """
2866
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2867
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2868
+ print(result)
2869
+
2870
+
2871
+ async def cmd_wait_for(selector, timeout_ms=5000):
2872
+ """Wait for element to appear in DOM, up to timeout."""
2873
+ ws_url, _ = get_page_ws()
2874
+ safe_sel = json.dumps(selector)
2875
+ js = f"""
2876
+ (function() {{
2877
+ return new Promise(function(resolve) {{
2878
+ var sel = {safe_sel};
2879
+ var el = document.querySelector(sel);
2880
+ if (el) return resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"');
2881
+ var obs = new MutationObserver(function() {{
2882
+ var el = document.querySelector(sel);
2883
+ if (el) {{ obs.disconnect(); resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"'); }}
2884
+ }});
2885
+ obs.observe(document.body, {{childList: true, subtree: true}});
2886
+ setTimeout(function() {{ obs.disconnect(); resolve('TIMEOUT: ' + sel + ' not found after {timeout_ms}ms'); }}, {timeout_ms});
2887
+ }});
2888
+ }})()
2889
+ """
2890
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})], timeout=max(15, timeout_ms // 1000 + 5))
2891
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2892
+ print(result)
2893
+
2894
+
2895
+ async def cmd_check(checks_json=None):
2896
+ """Run batch assertions. Input: JSON array of {selector, text?} objects."""
2897
+ ws_url, _ = get_page_ws()
2898
+ if checks_json is None:
2899
+ raw = sys.stdin.read().strip()
2900
+ else:
2901
+ raw = checks_json
2902
+ try:
2903
+ checks = json.loads(raw)
2904
+ except (json.JSONDecodeError, TypeError):
2905
+ print("Error: Invalid JSON. Expected: [{\"selector\": \"...\", \"text\": \"...\"}]")
2906
+ sys.exit(1)
2907
+
2908
+ passed = 0
2909
+ failed = 0
2910
+ results = []
2911
+ for i, check in enumerate(checks, 1):
2912
+ sel = check.get("selector", "")
2913
+ text = check.get("text")
2914
+ safe_sel = json.dumps(sel)
2915
+ text_check = ""
2916
+ if text:
2917
+ safe_text = json.dumps(text)
2918
+ text_check = f"""
2919
+ var actual = el.textContent || el.value || '';
2920
+ if (actual.indexOf({safe_text}) === -1) {{
2921
+ return 'FAIL: Expected ' + {safe_text} + ' in: ' + actual.substring(0, 80);
2922
+ }}"""
2923
+ js = f"""
2924
+ (function() {{
2925
+ var el = document.querySelector({safe_sel});
2926
+ if (!el) return 'FAIL: Not found: ' + {safe_sel};
2927
+ {text_check}
2928
+ return 'PASS: ' + el.tagName + ' (' + {safe_sel} + ')';
2929
+ }})()
2930
+ """
2931
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2932
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2933
+ if result.startswith("PASS"):
2934
+ passed += 1
2935
+ else:
2936
+ failed += 1
2937
+ results.append(f" {i}. {result}")
2938
+
2939
+ print(f"Test Report: {passed} passed, {failed} failed")
2940
+ print("─" * 40)
2941
+ for line in results:
2942
+ print(line)
2943
+
2944
+
2945
+ async def cmd_assert_url(expected_url):
2946
+ """Assert current page URL contains the expected substring."""
2947
+ ws_url, _ = get_page_ws()
2948
+ safe_expected = json.dumps(expected_url)
2949
+ js = f"""(function() {{
2950
+ var href = window.location.href;
2951
+ var expected = {safe_expected};
2952
+ if (href.indexOf(expected) !== -1) return 'PASS: URL ' + href + ' contains \"' + expected + '\"';
2953
+ return 'FAIL: URL ' + href + ' does not contain \"' + expected + '\"';
2954
+ }})()"""
2955
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2956
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2957
+ print(result)
2958
+
2959
+
2960
+ async def cmd_assert_title(expected_title):
2961
+ """Assert page title contains the expected substring."""
2962
+ ws_url, _ = get_page_ws()
2963
+ safe_expected = json.dumps(expected_title)
2964
+ js = f"""(function() {{
2965
+ var title = document.title;
2966
+ var expected = {safe_expected};
2967
+ if (title.indexOf(expected) !== -1) return 'PASS: Title \"' + title + '\" contains \"' + expected + '\"';
2968
+ return 'FAIL: Title \"' + title + '\" does not contain \"' + expected + '\"';
2969
+ }})()"""
2970
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2971
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2972
+ print(result)
2973
+
2974
+
2975
+ async def cmd_assert_count(selector, expected_count):
2976
+ """Assert the number of elements matching a CSS selector equals expected_count."""
2977
+ ws_url, _ = get_page_ws()
2978
+ safe_sel = json.dumps(selector)
2979
+ exp = int(expected_count)
2980
+ js = f"""(function() {{
2981
+ var count = document.querySelectorAll({safe_sel}).length;
2982
+ var exp = {exp};
2983
+ if (count === exp) return 'PASS: Found ' + count + ' element(s) matching {safe_sel} (expected ' + exp + ')';
2984
+ return 'FAIL: Expected ' + exp + ' \"{selector}\" but found ' + count;
2985
+ }})()"""
2986
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2987
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
2988
+ print(result)
2989
+
2990
+
2991
+ async def cmd_assert_value(selector, expected_value):
2992
+ """Assert an input/textarea/select element's value equals expected_value."""
2993
+ ws_url, _ = get_page_ws()
2994
+ safe_sel = json.dumps(selector)
2995
+ safe_expected = json.dumps(expected_value)
2996
+ js = f"""(function() {{
2997
+ var el = document.querySelector({safe_sel});
2998
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
2999
+ var val = el.value;
3000
+ var expected = {safe_expected};
3001
+ if (val === expected) return 'PASS: Value matches \"' + expected + '\"';
3002
+ return 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
3003
+ }})()"""
3004
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3005
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3006
+ print(result)
3007
+
3008
+
3009
+ async def cmd_assert_attr(selector, attr, expected):
3010
+ """Assert element attribute value contains expected substring."""
3011
+ ws_url, _ = get_page_ws()
3012
+ safe_sel = json.dumps(selector)
3013
+ safe_attr = json.dumps(attr)
3014
+ safe_expected = json.dumps(expected)
3015
+ js = f"""(function() {{
3016
+ var el = document.querySelector({safe_sel});
3017
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
3018
+ var val = el.getAttribute({safe_attr}) || '';
3019
+ var expected = {safe_expected};
3020
+ if (val.indexOf(expected) !== -1) return 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"';
3021
+ return 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
3022
+ }})()"""
3023
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3024
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3025
+ print(result)
3026
+
3027
+
3028
+ async def cmd_assert_visible(selector, should_be_visible=True):
3029
+ """Assert element is visible (or hidden). should_be_visible=True checks for visible, False for hidden."""
3030
+ ws_url, _ = get_page_ws()
3031
+ safe_sel = json.dumps(selector)
3032
+ expect_label = "visible" if should_be_visible else "hidden"
3033
+ opposite_label = "hidden" if should_be_visible else "visible"
3034
+ js = f"""(function() {{
3035
+ var el = document.querySelector({safe_sel});
3036
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
3037
+ var style = window.getComputedStyle(el);
3038
+ var rect = el.getBoundingClientRect();
3039
+ var isVisible = (
3040
+ style.display !== 'none' &&
3041
+ style.visibility !== 'hidden' &&
3042
+ style.opacity !== '0' &&
3043
+ (rect.width > 0 || rect.height > 0)
3044
+ );
3045
+ var expectVisible = {str(should_be_visible).lower()};
3046
+ if (isVisible === expectVisible) return 'PASS: ' + {safe_sel} + ' is {expect_label}';
3047
+ return 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
3048
+ }})()"""
3049
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3050
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3051
+ print(result)
3052
+
3053
+
3054
+ async def cmd_screenshot_diff(path1, path2):
3055
+ """Compare two screenshot files byte-by-byte. No CDP required."""
3056
+ for path in (path1, path2):
3057
+ if not os.path.exists(path):
3058
+ print(f"ERROR: File not found: {path}")
3059
+ return
3060
+ size1 = os.path.getsize(path1)
3061
+ size2 = os.path.getsize(path2)
3062
+ with open(path1, "rb") as f1, open(path2, "rb") as f2:
3063
+ data1 = f1.read()
3064
+ data2 = f2.read()
3065
+ if data1 == data2:
3066
+ print("MATCH: Files are identical")
3067
+ else:
3068
+ kb1 = size1 / 1024
3069
+ kb2 = size2 / 1024
3070
+ print(f"DIFF: Files differ ({os.path.basename(path1)}: {kb1:.1f}KB, {os.path.basename(path2)}: {kb2:.1f}KB)")
3071
+
3072
+
3073
+ async def cmd_a11y_snapshot():
3074
+ """Output a compact accessibility snapshot for AI agent navigation.
3075
+
3076
+ Each line: @ref [role] "name" attributes...
3077
+ Use 'click-ref @N' to click an element by its reference number.
3078
+ """
3079
+ global _A11Y_REF_MAP
3080
+ _A11Y_REF_MAP = {}
3081
+ ws_url, _ = get_page_ws()
3082
+ await cdp_send(ws_url, [(0, "Accessibility.enable", {})])
3083
+ res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
3084
+ nodes = res.get(1, {}).get("nodes", [])
3085
+ if not nodes:
3086
+ print("Could not get accessibility tree.", file=sys.stderr)
3087
+ sys.exit(1)
3088
+
3089
+ ROLE_NORMALIZE = {
3090
+ "textField": "textbox",
3091
+ "comboBox": "combobox",
3092
+ "checkBox": "checkbox",
3093
+ "radioButton": "radio",
3094
+ }
3095
+
3096
+ SKIP_ROLES = {
3097
+ "none", "presentation", "generic", "LineBreak", "InlineTextBox",
3098
+ "ignored", "unknown"
3099
+ }
3100
+
3101
+ INTERACTIVE_ROLES = {
3102
+ "button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
3103
+ "menuitem", "menuitemcheckbox", "menuitemradio", "option", "spinbutton",
3104
+ "slider", "switch", "tab", "treeitem"
3105
+ }
3106
+
3107
+ STRUCTURAL_ROLES = {
3108
+ "heading", "img", "navigation", "menu", "list", "listitem", "table",
3109
+ "grid", "row", "cell", "columnheader", "rowheader", "dialog", "alert",
3110
+ "main", "banner", "contentinfo", "region", "figure", "article", "section"
3111
+ }
3112
+
3113
+ def _get_prop(node, prop_name):
3114
+ for p in node.get("properties", []):
3115
+ if p.get("name") == prop_name:
3116
+ return p.get("value", {}).get("value", "")
3117
+ return ""
3118
+
3119
+ ref_count = 0
3120
+ interactive_count = 0
3121
+ output_lines = []
3122
+
3123
+ for node in nodes:
3124
+ backend_node_id = node.get("backendDOMNodeId") or node.get("backendNodeId")
3125
+ role = node.get("role", {}).get("value")
3126
+ name = node.get("name", {}).get("value")
3127
+ description = node.get("description", {}).get("value")
3128
+ ignored = node.get("ignored", False)
3129
+
3130
+ if ignored:
3131
+ continue
3132
+ if not role or role in SKIP_ROLES:
3133
+ continue
3134
+ if not backend_node_id:
3135
+ continue
3136
+ if not name and not description and role in {"staticText", "text", "paragraph"}:
3137
+ continue
3138
+
3139
+ normalized = ROLE_NORMALIZE.get(role, role)
3140
+
3141
+ # heading/N format
3142
+ if normalized == "heading":
3143
+ level = _get_prop(node, "level")
3144
+ if level:
3145
+ normalized = f"heading/{level}"
3146
+
3147
+ # Determine inclusion
3148
+ base_role = normalized.split("/")[0]
3149
+ is_interactive = base_role in INTERACTIVE_ROLES
3150
+ is_structural = base_role in STRUCTURAL_ROLES
3151
+ if not (is_interactive or (is_structural and (name or description))):
3152
+ continue
3153
+
3154
+ display_name = name or description or ""
3155
+
3156
+ attrs = []
3157
+
3158
+ if base_role == "link":
3159
+ href = _get_prop(node, "url")
3160
+ if href:
3161
+ attrs.append(f"href={href}")
3162
+
3163
+ if _get_prop(node, "disabled") == "true":
3164
+ attrs.append("disabled")
3165
+
3166
+ if _get_prop(node, "required") == "true":
3167
+ attrs.append("required")
3168
+
3169
+ if base_role in {"textbox", "combobox"}:
3170
+ val = _get_prop(node, "value")
3171
+ if val:
3172
+ attrs.append(f"value={val!r}")
3173
+ ph = _get_prop(node, "placeholder")
3174
+ if ph:
3175
+ attrs.append(f"placeholder={ph!r}")
3176
+
3177
+ if base_role in {"checkbox", "radio"}:
3178
+ if _get_prop(node, "checked") == "true":
3179
+ attrs.append("checked")
3180
+
3181
+ if _get_prop(node, "expanded") == "true":
3182
+ attrs.append("expanded")
3183
+
3184
+ ref_count += 1
3185
+ _A11Y_REF_MAP[ref_count] = backend_node_id
3186
+
3187
+ if is_interactive:
3188
+ interactive_count += 1
3189
+
3190
+ attr_str = (" " + " ".join(attrs)) if attrs else ""
3191
+ line = f"@{ref_count} [{normalized}] \"{display_name}\"{attr_str}"
3192
+ output_lines.append(line)
3193
+
3194
+ _save_a11y_refs(_A11Y_REF_MAP)
3195
+
3196
+ for line in output_lines:
3197
+ print(line)
3198
+ print(f"\n[{interactive_count} interactive, {ref_count} total shown]")
3199
+
3200
+
3201
+ async def cmd_click_ref(ref_str):
3202
+ """Click an element by its @N reference from the last a11y-snapshot."""
3203
+ ws_url, _ = get_page_ws()
3204
+
3205
+ try:
3206
+ ref_num = int(ref_str.lstrip("@"))
3207
+ except ValueError:
3208
+ print(f"Error: Invalid reference '{ref_str}'. Expected @N (e.g. @3).", file=sys.stderr)
3209
+ sys.exit(1)
3210
+
3211
+ ref_map = _A11Y_REF_MAP or _load_a11y_refs()
3212
+ backend_node_id = ref_map.get(ref_num)
3213
+ if not backend_node_id:
3214
+ print(f"Error: Reference '@{ref_num}' not found. Run 'a11y-snapshot' first.", file=sys.stderr)
3215
+ sys.exit(1)
3216
+
3217
+ # Get element box model via backendNodeId directly
3218
+ await cdp_send(ws_url, [(0, "DOM.enable", {})])
3219
+ res1 = await cdp_send(ws_url, [(1, "DOM.getBoxModel", {"backendNodeId": backend_node_id})])
3220
+ model = res1.get(1, {}).get("model")
3221
+ if not model:
3222
+ # Fallback: resolve to objectId and use JS
3223
+ res_r = await cdp_send(ws_url, [(10, "DOM.resolveNode", {"backendNodeId": backend_node_id})])
3224
+ oid = res_r.get(10, {}).get("object", {}).get("objectId")
3225
+ if oid:
3226
+ res_js = await cdp_send(ws_url, [(11, "Runtime.callFunctionOn", {
3227
+ "functionDeclaration": "function(){var r=this.getBoundingClientRect();return{x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)};}",
3228
+ "objectId": oid, "returnByValue": True,
3229
+ })])
3230
+ val = res_js.get(11, {}).get("result", {}).get("value")
3231
+ if val and "x" in val:
3232
+ model = None # skip box model path
3233
+ x, y = val["x"], val["y"]
3234
+ if model:
3235
+ # content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
3236
+ content = model.get("content", model.get("border", []))
3237
+ if len(content) >= 8:
3238
+ x = int((content[0] + content[2] + content[4] + content[6]) / 4)
3239
+ y = int((content[1] + content[3] + content[5] + content[7]) / 4)
3240
+ val = {"x": x, "y": y}
3241
+ else:
3242
+ val = None
3243
+ elif not model and 'x' not in dir():
3244
+ val = None
3245
+
3246
+ if not val or "x" not in val:
3247
+ print(f"Error: Could not get coordinates for @{ref_num}.", file=sys.stderr)
3248
+ sys.exit(1)
3249
+
3250
+ x, y = val["x"], val["y"]
3251
+ await _vfx_ripple(ws_url, x, y)
3252
+ await cdp_send(ws_url, [
3253
+ (3, "Input.dispatchMouseEvent", {
3254
+ "type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1
3255
+ }),
3256
+ (4, "Input.dispatchMouseEvent", {
3257
+ "type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1
3258
+ }),
3259
+ ])
3260
+ print(f"Clicked @{ref_num} (backendNodeId={backend_node_id}): ({x}, {y})")
3261
+
3262
+
1861
3263
  # ─── 3. Advanced Input Commands ───
1862
3264
 
1863
3265
  async def cmd_hover(selector):
1864
3266
  """Move the mouse cursor to the specified element."""
1865
3267
  ws_url, _ = get_page_ws()
1866
3268
  x, y = await _get_element_center(ws_url, selector)
3269
+ await _vfx_move_cursor(ws_url, x, y)
1867
3270
  await cdp_send(ws_url, [(1, "Input.dispatchMouseEvent",
1868
3271
  {"type": "mouseMoved", "x": x, "y": y, "button": "none", "modifiers": 0})])
1869
3272
  print(f"Hover: {selector} ({x}, {y})")
@@ -1873,6 +3276,7 @@ async def cmd_dblclick(selector):
1873
3276
  """Double-click the specified element."""
1874
3277
  ws_url, _ = get_page_ws()
1875
3278
  x, y = await _get_element_center(ws_url, selector)
3279
+ await _vfx_ripple(ws_url, x, y)
1876
3280
  cmds = [
1877
3281
  (1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1}),
1878
3282
  (2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1}),
@@ -1887,6 +3291,7 @@ async def cmd_rightclick(selector):
1887
3291
  """Right-click the specified element."""
1888
3292
  ws_url, _ = get_page_ws()
1889
3293
  x, y = await _get_element_center(ws_url, selector)
3294
+ await _vfx_ripple(ws_url, x, y)
1890
3295
  cmds = [
1891
3296
  (1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "right", "clickCount": 1}),
1892
3297
  (2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "right", "clickCount": 1}),
@@ -1900,6 +3305,7 @@ async def cmd_drag(from_selector, to_selector):
1900
3305
  ws_url, _ = get_page_ws()
1901
3306
  fx, fy = await _get_element_center(ws_url, from_selector)
1902
3307
  tx, ty = await _get_element_center(ws_url, to_selector)
3308
+ await _vfx_ripple(ws_url, fx, fy)
1903
3309
 
1904
3310
  import websockets
1905
3311
  async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
@@ -1913,6 +3319,7 @@ async def cmd_drag(from_selector, to_selector):
1913
3319
  for i in range(1, steps + 1):
1914
3320
  ix = int(fx + (tx - fx) * i / steps)
1915
3321
  iy = int(fy + (ty - fy) * i / steps)
3322
+ await _vfx_move_cursor(ws_url, ix, iy)
1916
3323
  await send_mouse(10 + i, "mouseMoved", ix, iy)
1917
3324
  await asyncio.sleep(0.05)
1918
3325
  await send_mouse(20, "mouseReleased", tx, ty)
@@ -1957,6 +3364,7 @@ async def cmd_keys(combo):
1957
3364
  print("Error: specify a valid key (e.g. ctrl+a, enter, tab).", file=sys.stderr)
1958
3365
  sys.exit(1)
1959
3366
 
3367
+ await _vfx_keystroke(ws_url, combo.upper())
1960
3368
  cmds = [
1961
3369
  (1, "Input.dispatchKeyEvent", {"type": "keyDown", "modifiers": modifiers,
1962
3370
  "key": key_name, "windowsVirtualKeyCode": key_code, "nativeVirtualKeyCode": key_code}),
@@ -2222,34 +3630,58 @@ class MCPServer:
2222
3630
 
2223
3631
  def _register_tools(self):
2224
3632
  return [
2225
- {"name": "browser_navigate", "description": "Navigate to a URL",
2226
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to"}}, "required": ["url"]}},
2227
- {"name": "browser_screenshot", "description": "Take a screenshot of the current page",
2228
- "inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output filename", "default": "screenshot.png"}}}},
2229
- {"name": "browser_click", "description": "Click an element by CSS selector",
2230
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}}, "required": ["selector"]}},
2231
- {"name": "browser_type", "description": "Type text into an input element",
2232
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "text": {"type": "string", "description": "Text to type"}}, "required": ["selector", "text"]}},
2233
- {"name": "browser_content", "description": "Get text content of the current page",
3633
+ {"name": "browser_navigate", "description": "Navigate the browser to a URL and return the page text content. Use this to open websites, follow links, or load web applications. Waits for page load before returning. Returns first 10000 chars of visible text.",
3634
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "Full URL to navigate to (must include https://)"}}, "required": ["url"]}},
3635
+ {"name": "browser_screenshot", "description": "Capture a screenshot of the current browser viewport. Supports element-level cropping to save tokens (capture only a specific element instead of full page). Use JPEG format with quality parameter for smaller files (~5x smaller than PNG). Prefer element cropping + JPEG for token-efficient AI workflows.",
3636
+ "inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output file path (e.g. /tmp/screenshot.png). Extension determines format: .png, .jpg, .webp", "default": "screenshot.png"}, "element": {"type": "string", "description": "CSS selector to capture only that element (crops to bounding box). Saves ~3-7x tokens vs full page."}, "quality": {"type": "number", "description": "JPEG quality 1-100 (only for .jpg files). Lower = smaller file = fewer tokens. Default: 80"}}}},
3637
+ {"name": "browser_click", "description": "Click an element on the page identified by CSS selector. Auto-waits up to 5 seconds for the element to appear (MutationObserver). Scrolls the element into view before clicking. Returns the tag name and text of the clicked element.",
3638
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to click (e.g. '#submit-btn', '.nav a', 'button[type=submit]')"}}, "required": ["selector"]}},
3639
+ {"name": "browser_type", "description": "Type text into an input or textarea element. Uses React-compatible value setting (native setter + input/change events). Auto-waits up to 5 seconds for the element. Use browser_fill as an alias.",
3640
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "text": {"type": "string", "description": "Text value to type into the element"}}, "required": ["selector", "text"]}},
3641
+ {"name": "browser_content", "description": "Get the visible text content of the current page (document.body.innerText). Returns up to 10000 characters. Use this to read page content without HTML markup. Prefer browser_a11y for structured navigation data.",
3642
+ "inputSchema": {"type": "object", "properties": {}}},
3643
+ {"name": "browser_html", "description": "Get the full HTML source of the current page (document.documentElement.outerHTML). Returns up to 50000 characters. Use sparingly — prefer browser_content or browser_a11y for most tasks as they return smaller, more useful data.",
2234
3644
  "inputSchema": {"type": "object", "properties": {}}},
2235
- {"name": "browser_html", "description": "Get HTML source of the current page",
3645
+ {"name": "browser_eval", "description": "Execute arbitrary JavaScript code in the browser page context and return the result. Use for custom DOM queries, data extraction, or page manipulation that other tools don't cover. Expression is evaluated via Runtime.evaluate.",
3646
+ "inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression to evaluate (e.g. 'document.title', 'document.querySelectorAll(\"a\").length')"}}, "required": ["expression"]}},
3647
+ {"name": "browser_tabs", "description": "List all open browser tabs with their IDs, URLs, and titles. Use this to see what pages are open and get tab IDs for switching between them with other navigation commands.",
2236
3648
  "inputSchema": {"type": "object", "properties": {}}},
2237
- {"name": "browser_eval", "description": "Execute JavaScript in the browser",
2238
- "inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression"}}, "required": ["expression"]}},
2239
- {"name": "browser_tabs", "description": "List all open browser tabs",
3649
+ {"name": "browser_console", "description": "Navigate to a URL and capture all browser console output (log, warn, error, info) and uncaught exceptions. Use for debugging JavaScript errors, monitoring API calls logged to console, or verifying application behavior.",
3650
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while capturing console output"}}}},
3651
+ {"name": "browser_network", "description": "Navigate to a URL and monitor all network requests/responses. Returns each request's URL, HTTP status code, and MIME type. Use for debugging API calls, checking resource loading, or verifying network behavior.",
3652
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while monitoring network traffic"}}}},
3653
+ {"name": "browser_a11y", "description": "Get the accessibility tree of the current page as structured data. Returns interactive elements with @N references that can be used with browser_click. This is the PREFERRED way to understand page structure — uses ~500 tokens vs ~250K for screenshots. Use 'summary' mode for a compact view.",
3654
+ "inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "description": "Output detail level: 'full' for complete tree, 'summary' for interactive elements only", "default": "full"}}}},
3655
+ {"name": "browser_fill", "description": "Set an input element's value using React-compatible method (native descriptor setter + input/change events). Auto-waits up to 5 seconds for the element. Works with regular inputs, textareas, and React controlled components.",
3656
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
3657
+ {"name": "browser_launch", "description": "Launch an isolated browser instance with Chrome DevTools Protocol enabled. Uses existing Brave/Chrome/Chromium installation — no browser download needed. Creates an isolated profile directory so your personal browser data is never touched.",
2240
3658
  "inputSchema": {"type": "object", "properties": {}}},
2241
- {"name": "browser_console", "description": "Get console logs from the browser",
2242
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and capture logs"}}}},
2243
- {"name": "browser_network", "description": "Monitor network requests",
2244
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and monitor"}}}},
2245
- {"name": "browser_a11y", "description": "Get accessibility tree of the current page",
2246
- "inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "default": "full"}}}},
2247
- {"name": "browser_fill", "description": "Set input value (React-compatible)",
2248
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "value": {"type": "string", "description": "Value to set"}}, "required": ["selector", "value"]}},
2249
- {"name": "browser_launch", "description": "Launch browser with CDP enabled",
3659
+ {"name": "browser_close", "description": "Close the currently active browser tab. Use this to clean up after automation tasks or to close unwanted popups/tabs that were opened during navigation.",
2250
3660
  "inputSchema": {"type": "object", "properties": {}}},
2251
- {"name": "browser_close", "description": "Close the active tab",
3661
+ {"name": "browser_describe", "description": "Get a comprehensive page description combining three data sources: (1) accessibility tree with @N references for interactive elements, (2) a PNG screenshot saved to disk, and (3) visible text content. Use this when browser_a11y alone is insufficient — for canvas/WebGL content, visual verification, or complex dynamic UIs. This is the vision fallback tool.",
2252
3662
  "inputSchema": {"type": "object", "properties": {}}},
3663
+ {"name": "browser_assert", "description": "Assert that an element matching the CSS selector exists and optionally contains expected text. Returns PASS or FAIL with details. Use this for automated testing — verify page state after navigation or interaction. Checks visibility by default.",
3664
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to check for existence"}, "text": {"type": "string", "description": "Optional: expected text content (substring match)"}, "visible": {"type": "boolean", "description": "Check element is visible (not hidden/zero-size)", "default": True}}, "required": ["selector"]}},
3665
+ {"name": "browser_wait_for", "description": "Wait for an element matching the CSS selector to appear in the DOM, up to the specified timeout. Uses MutationObserver for efficient waiting. Returns the element's tag and text when found, or TIMEOUT if not found. Use before interactions with dynamically loaded content.",
3666
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to wait for"}, "timeout": {"type": "number", "description": "Maximum wait time in milliseconds", "default": 5000}}, "required": ["selector"]}},
3667
+ {"name": "browser_check", "description": "Run a batch of assertions on the current page and return a test report. Each check verifies element existence and optional text content. Returns a summary with PASS/FAIL count. Use this for comprehensive page validation after a series of actions.",
3668
+ "inputSchema": {"type": "object", "properties": {"checks": {"type": "array", "description": "Array of checks, each with 'selector' (required) and 'text' (optional)", "items": {"type": "object", "properties": {"selector": {"type": "string"}, "text": {"type": "string"}}, "required": ["selector"]}}}, "required": ["checks"]}},
3669
+ {"name": "browser_assert_url", "description": "Assert the current page URL contains the expected substring. Returns PASS with the full URL or FAIL. Use this after navigation to verify you landed on the correct page.",
3670
+ "inputSchema": {"type": "object", "properties": {"expected_url": {"type": "string", "description": "Expected substring to find in the current URL (e.g. 'example.com', '/dashboard', '?tab=settings')"}}, "required": ["expected_url"]}},
3671
+ {"name": "browser_assert_title", "description": "Assert the current page title contains the expected substring. Returns PASS with full title or FAIL. Useful for verifying page identity without relying on URL.",
3672
+ "inputSchema": {"type": "object", "properties": {"expected_title": {"type": "string", "description": "Expected substring to find in the page title (e.g. 'Dashboard', 'Login')"}}, "required": ["expected_title"]}},
3673
+ {"name": "browser_assert_count", "description": "Assert the number of elements matching a CSS selector equals an expected count. Returns PASS with count or FAIL with actual vs expected. Use this to verify list items, table rows, search results, or repeated components.",
3674
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to count matching elements"}, "expected_count": {"type": "integer", "description": "Expected number of matching elements"}}, "required": ["selector", "expected_count"]}},
3675
+ {"name": "browser_assert_value", "description": "Assert an input, textarea, or select element's current value equals the expected string. Returns PASS or FAIL with actual value. Use this to verify form field state after filling or after page load.",
3676
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input/textarea/select element"}, "expected_value": {"type": "string", "description": "Expected exact value of the element"}}, "required": ["selector", "expected_value"]}},
3677
+ {"name": "browser_assert_attr", "description": "Assert an element's HTML attribute contains the expected substring. Returns PASS with actual value or FAIL. Use this to verify href, src, data-*, aria-* and other attributes without reading full page HTML.",
3678
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element"}, "attr": {"type": "string", "description": "Attribute name (e.g. 'href', 'src', 'data-id', 'aria-label')"}, "expected": {"type": "string", "description": "Expected substring in the attribute value"}}, "required": ["selector", "attr", "expected"]}},
3679
+ {"name": "browser_assert_visible", "description": "Assert an element is visible on the page (not hidden by CSS). Checks display, visibility, opacity and bounding rect. Returns PASS or FAIL. Use this to verify modals opened, elements shown after interaction, or content loaded.",
3680
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to check for visibility"}}, "required": ["selector"]}},
3681
+ {"name": "browser_assert_hidden", "description": "Assert an element exists but is hidden (display:none, visibility:hidden, opacity:0, or zero size). Returns PASS or FAIL. Use this to verify modals closed, tooltips dismissed, or conditional sections hidden.",
3682
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
3683
+ {"name": "browser_screenshot_diff", "description": "Compare two screenshot PNG files byte-by-byte. Returns MATCH if files are identical or DIFF with file sizes if different. Use this for visual regression testing — take a baseline screenshot, perform actions, take another screenshot, then compare.",
3684
+ "inputSchema": {"type": "object", "properties": {"path1": {"type": "string", "description": "Absolute path to the first (baseline) screenshot PNG"}, "path2": {"type": "string", "description": "Absolute path to the second (current) screenshot PNG"}}, "required": ["path1", "path2"]}},
2253
3685
  ]
2254
3686
 
2255
3687
  def _handle_request(self, request):
@@ -2287,7 +3719,7 @@ class MCPServer:
2287
3719
  import io, subprocess
2288
3720
  tool_map = {
2289
3721
  "browser_navigate": lambda a: ["go", a.get("url", "")],
2290
- "browser_screenshot": lambda a: ["shot"] + ([self._safe_filename(a["filename"])] if a.get("filename") else []),
3722
+ "browser_screenshot": lambda a: ["shot"] + ([self._safe_filename(a["filename"])] if a.get("filename") else []) + ([f"--element={a['element']}"] if a.get("element") else []) + ([f"--quality={a['quality']}"] if a.get("quality") else []),
2291
3723
  "browser_click": lambda a: ["click", a.get("selector", "")],
2292
3724
  "browser_type": lambda a: ["type", a.get("selector", ""), a.get("text", "")],
2293
3725
  "browser_content": lambda a: ["content"],
@@ -2300,15 +3732,29 @@ class MCPServer:
2300
3732
  "browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
2301
3733
  "browser_launch": lambda a: ["launch"],
2302
3734
  "browser_close": lambda a: ["close"],
3735
+ "browser_describe": lambda a: ["describe"],
3736
+ "browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
3737
+ "browser_wait_for": lambda a: ["wait-for", a.get("selector", "")] + ([str(a["timeout"])] if a.get("timeout") else []),
3738
+ "browser_check": lambda a: ["check", json.dumps(a.get("checks", []))],
3739
+ "browser_assert_url": lambda a: ["assert-url", a.get("expected_url", "")],
3740
+ "browser_assert_title": lambda a: ["assert-title", a.get("expected_title", "")],
3741
+ "browser_assert_count": lambda a: ["assert-count", a.get("selector", ""), str(a.get("expected_count", 0))],
3742
+ "browser_assert_value": lambda a: ["assert-value", a.get("selector", ""), a.get("expected_value", "")],
3743
+ "browser_assert_attr": lambda a: ["assert-attr", a.get("selector", ""), a.get("attr", ""), a.get("expected", "")],
3744
+ "browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
3745
+ "browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
3746
+ "browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
2303
3747
  }
2304
3748
  if tool_name not in tool_map:
2305
3749
  return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
2306
3750
 
2307
3751
  cli_args = [a for a in tool_map[tool_name](args) if a]
2308
3752
  try:
3753
+ env = os.environ.copy()
3754
+ env["CDPILOT_MCP_SESSION"] = "1"
2309
3755
  result = subprocess.run(
2310
3756
  [sys.executable, __file__] + cli_args,
2311
- capture_output=True, text=True, timeout=30
3757
+ capture_output=True, text=True, timeout=30, env=env
2312
3758
  )
2313
3759
  output = result.stdout.strip()
2314
3760
  errors = result.stderr.strip()
@@ -2373,6 +3819,9 @@ if __name__ == "__main__":
2373
3819
  'session': cmd_session,
2374
3820
  'sessions': cmd_sessions,
2375
3821
  'session-close': lambda: cmd_session_close(args[0] if args else None),
3822
+ 'projects': cmd_projects,
3823
+ 'project-stop': lambda: cmd_project_stop(args[0] if args else ''),
3824
+ 'stop-all': cmd_stop_all,
2376
3825
  }
2377
3826
 
2378
3827
  if cmd == "mcp":
@@ -2414,7 +3863,14 @@ if __name__ == "__main__":
2414
3863
  "go": lambda: (require_args(1, "go <url>"), cmd_go(args[0]))[1] if not args else cmd_go(args[0]),
2415
3864
  "content": cmd_content,
2416
3865
  "html": cmd_html,
2417
- "shot": lambda: cmd_shot(args[0] if args else None),
3866
+ "shot": lambda: cmd_shot(
3867
+ output=next((a for a in args if not a.startswith("--")), None),
3868
+ quality=next((a.split("=")[1] for a in args if a.startswith("--quality=")), None),
3869
+ element=next((a.split("=")[1] for a in args if a.startswith("--element=")), None),
3870
+ fmt=next((a.split("=")[1] for a in args if a.startswith("--format=")), None),
3871
+ ),
3872
+ "shot-annotated": lambda: cmd_shot_annotated(args[0] if args else None),
3873
+ "batch": cmd_batch,
2418
3874
  "eval": lambda: (require_args(1, "eval <js>"), None)[1] if not args else cmd_eval(" ".join(args)),
2419
3875
  "click": lambda: (require_args(1, "click <selector>"), None)[1] if not args else cmd_click(args[0]),
2420
3876
  "fill": lambda: (require_args(2, "fill <selector> <value>"), None)[1] if len(args) < 2 else cmd_fill(args[0], " ".join(args[1:])),
@@ -2438,6 +3894,20 @@ if __name__ == "__main__":
2438
3894
  'multi-eval': lambda: (require_args(1, 'multi-eval <js>'), None)[1] if not args else cmd_multi_eval(' '.join(args)),
2439
3895
  'intercept': lambda: (require_args(1, 'intercept [block|mock|headers|clear|list] ...'), None)[1] if not args else cmd_intercept(args[0], *args[1:]),
2440
3896
  'a11y': lambda: cmd_a11y(' '.join(args)),
3897
+ 'a11y-snapshot': cmd_a11y_snapshot,
3898
+ 'describe': cmd_describe,
3899
+ 'assert': lambda: (require_args(1, 'assert <selector> [text]'), None)[1] if not args else cmd_assert(args[0], args[1] if len(args) > 1 else None),
3900
+ 'wait-for': lambda: (require_args(1, 'wait-for <selector> [timeout_ms]'), None)[1] if not args else cmd_wait_for(args[0], int(args[1]) if len(args) > 1 else 5000),
3901
+ 'check': lambda: cmd_check(args[0] if args else None),
3902
+ 'assert-url': lambda: (require_args(1, 'assert-url <expected>'), None)[1] if not args else cmd_assert_url(args[0]),
3903
+ 'assert-title': lambda: (require_args(1, 'assert-title <expected>'), None)[1] if not args else cmd_assert_title(args[0]),
3904
+ 'assert-count': lambda: (require_args(2, 'assert-count <selector> <n>'), None)[1] if len(args) < 2 else cmd_assert_count(args[0], int(args[1])),
3905
+ 'assert-value': lambda: (require_args(2, 'assert-value <selector> <value>'), None)[1] if len(args) < 2 else cmd_assert_value(args[0], args[1]),
3906
+ 'assert-attr': lambda: (require_args(3, 'assert-attr <selector> <attr> <expected>'), None)[1] if len(args) < 3 else cmd_assert_attr(args[0], args[1], args[2]),
3907
+ 'assert-visible': lambda: (require_args(1, 'assert-visible <selector>'), None)[1] if not args else cmd_assert_visible(args[0], True),
3908
+ 'assert-hidden': lambda: (require_args(1, 'assert-hidden <selector>'), None)[1] if not args else cmd_assert_visible(args[0], False),
3909
+ 'screenshot-diff': lambda: (require_args(2, 'screenshot-diff <path1> <path2>'), None)[1] if len(args) < 2 else cmd_screenshot_diff(args[0], args[1]),
3910
+ 'click-ref': lambda: (require_args(1, 'click-ref <@N>'), None)[1] if not args else cmd_click_ref(args[0]),
2441
3911
  'hover': lambda: (require_args(1, 'hover <selector>'), None)[1] if not args else cmd_hover(args[0]),
2442
3912
  'dblclick': lambda: (require_args(1, 'dblclick <selector>'), None)[1] if not args else cmd_dblclick(args[0]),
2443
3913
  'rightclick': lambda: (require_args(1, 'rightclick <selector>'), None)[1] if not args else cmd_rightclick(args[0]),
@@ -2454,7 +3924,8 @@ if __name__ == "__main__":
2454
3924
 
2455
3925
  # Commands that do not require the visual indicator / input blocker
2456
3926
  NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
2457
- 'dialog', 'download', 'throttle', 'permission', 'intercept'}
3927
+ 'dialog', 'download', 'throttle', 'permission', 'intercept',
3928
+ 'batch', 'screenshot-diff'}
2458
3929
  # Clean up idle sessions before running any command
2459
3930
  _cleanup_idle_sessions()
2460
3931
 
@@ -2483,5 +3954,10 @@ if __name__ == "__main__":
2483
3954
  asyncio.run(_wrapped())
2484
3955
  _update_session_timestamp()
2485
3956
  else:
2486
- print(f"Unknown command: {cmd}")
2487
- print(__doc__)
3957
+ print(f"Unknown command: {cmd}", file=sys.stderr)
3958
+ all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))
3959
+ matches = difflib.get_close_matches(cmd, all_cmds, n=1, cutoff=0.6)
3960
+ if matches:
3961
+ print(f"Did you mean: {matches[0]}?", file=sys.stderr)
3962
+ print(f"\nAvailable commands: {', '.join(all_cmds)}", file=sys.stderr)
3963
+ sys.exit(1)