cdpilot 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +31 -13
  2. package/package.json +1 -1
  3. package/src/cdpilot.py +910 -43
package/README.md CHANGED
@@ -282,22 +282,40 @@ Found a vulnerability? Please email the maintainer directly instead of opening a
282
282
 
283
283
  ## Roadmap
284
284
 
285
- We're building cdpilot into the lightweight alternative to Playwright MCP for AI agents. Here's what's coming:
286
-
287
- - [x] 40+ CLI commands (navigate, click, fill, screenshot, PDF, console, network...)
288
- - [x] MCP server for AI agent integration (Claude Code, etc.)
289
- - [x] Visual feedback system (green glow, cursor visualization, click ripples, keystroke display)
290
- - [x] AI control warning toast (appears when user tries to interact during automation)
291
- - [x] Multi-project browser isolation (each project gets its own port + profile automatically)
285
+ The only browser MCP with built-in test assertions. Here's what we've shipped and what's next:
286
+
287
+ ### Shipped
288
+
289
+ - [x] 60+ CLI commands (navigate, click, fill, screenshot, PDF, console, network...)
290
+ - [x] MCP server for AI agent integration (Claude Code, Cursor, etc.)
291
+ - [x] **10 built-in test assertions** — assert, assert-url, assert-title, assert-count, assert-value, assert-attr, assert-visible/hidden, wait-for, check (batch), screenshot-diff
292
+ - [x] **Accessibility tree snapshot** (`a11y-snapshot`) — structured data with @ref references, 500x fewer tokens than screenshots
293
+ - [x] **Token-efficient screenshots** — element-level crop (13x smaller), JPEG quality control, format selection
294
+ - [x] **Vision fallback** (`describe`) — a11y + screenshot + text in one call
295
+ - [x] **Annotated screenshots** — @N badge overlays on interactive elements
296
+ - [x] **Auto-wait** — MutationObserver-based, 5s automatic element waiting
297
+ - [x] **Batch commands** — pipe JSON arrays via stdin for multi-step automation
298
+ - [x] Visual feedback system (persistent green glow, cursor, ripples, keystroke display)
299
+ - [x] AI control warning toast (red warning when user interacts during automation)
300
+ - [x] Multi-project browser isolation (each project gets its own port + profile)
301
+ - [x] Pre-flight wizard (auto-installs dependencies on first run)
302
+ - [x] Persistent MCP glow (stays on during entire AI session, like Claude's orange glow)
292
303
  - [x] DevExtension system (native JS injection without browser store)
293
- - [ ] **Accessibility tree snapshot** — structured a11y data for AI agents (like Playwright MCP but 4x lighter)
294
- - [ ] **Annotated screenshots** — element references (@e1, @e2) overlaid on screenshots for precise AI clicking
295
- - [ ] **Auto-wait** — automatically wait for elements before interaction (reduce flakiness)
296
- - [ ] **Batch commands** — pipe JSON arrays via stdin for multi-step automation in one call
304
+ - [x] **Smart commands** — `smart-click`, `smart-fill`, `smart-select` — interact by visible text, no CSS selectors needed, no LLM required
305
+ - [x] **Data extraction** (`extract`) — structured DOM data in text, JSON, or list format
306
+ - [x] **Page observation** (`observe`) — list all interactive elements with available actions
307
+ - [x] **Script runner** (`run`) — execute `.cdp` script files with pass/fail reporting
308
+
309
+ ### Coming Soon
310
+
297
311
  - [ ] **iframe & Shadow DOM** support — interact with elements inside iframes and shadow roots
298
- - [ ] **Claude Code Skill mode** — run as a `.claude/skills/` skill in addition to MCP
299
312
  - [ ] **Session recording & replay** — record browser sessions and replay them deterministically
300
- - [ ] **Stealth mode** — anti-detection flags for web scraping scenarios
313
+ - [ ] **Stealth mode** *(Pro)* — human-like mouse/typing, anti-fingerprint, CAPTCHA solving
314
+ - [ ] **cdpilot Cloud** — hosted browser sessions API, REST + WebSocket MCP endpoint
315
+ - [ ] **Chrome Extension** — use cdpilot from any browser without CLI
316
+ - [ ] **Performance audit** — Core Web Vitals (LCP, CLS, INP) via CDP Performance domain
317
+ - [ ] **WCAG accessibility audit** — automated a11y compliance reporting
318
+ - [ ] **Claude Code Skill mode** — run as a `.claude/skills/` skill in addition to MCP
301
319
 
302
320
  Have an idea? [Open an issue](https://github.com/mehmetnadir/cdpilot/issues) or submit a PR!
303
321
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdpilot",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Zero-dependency browser automation from your terminal. One command, full control.",
5
5
  "bin": {
6
6
  "cdpilot": "./bin/cdpilot.js",
package/src/cdpilot.py CHANGED
@@ -39,6 +39,7 @@ CDPILOT_HOME = os.path.expanduser("~/.cdpilot")
39
39
  REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
40
40
  CDPILOT_PORT_RANGE_START = 9222
41
41
  CDPILOT_PORT_RANGE_END = 9322
42
+ IS_MCP_SESSION = os.environ.get("CDPILOT_MCP_SESSION") == "1"
42
43
 
43
44
 
44
45
  def _get_project_id():
@@ -565,10 +566,11 @@ async def _control_start(ws_url):
565
566
  pass
566
567
 
567
568
  async def _control_end(ws_url):
568
- """Remove input blocker, set glow/vfx auto-timeout (10s).
569
+ """Remove input blocker, keep glow alive.
569
570
 
570
- Persistent script stays active so glow survives page navigations.
571
- It gets cleaned up by the 10s timeout in GLOW_TIMEOUT_JS.
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).
572
574
  """
573
575
  global _glow_script_id
574
576
  try:
@@ -577,11 +579,12 @@ async def _control_end(ws_url):
577
579
  # Re-inject glow+vfx on current page (may be new after navigation)
578
580
  (906, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
579
581
  (907, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
580
- # Then start the 10s auto-cleanup timeout
581
- (904, "Runtime.evaluate", {"expression": GLOW_TIMEOUT_JS, "returnByValue": True}),
582
582
  ]
583
- # Don't remove persistent script here — it auto-cleans via GLOW_TIMEOUT_JS.
584
- # Next _control_start will replace it with a fresh one anyway.
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).
585
588
  _glow_script_id = None
586
589
  await cdp_send(ws_url, cmds)
587
590
  except Exception:
@@ -828,12 +831,12 @@ async def cdp_send(ws_url, commands, timeout=15):
828
831
  continue
829
832
  return results
830
833
  except ConnectionRefusedError:
831
- print(f'Browser is not running. Run \'cdpilot launch\' first.', file=sys.stderr)
834
+ print("Browser is not running. Run 'cdpilot launch' first.", file=sys.stderr)
832
835
  sys.exit(1)
833
836
  except Exception as e:
834
837
  err = str(e)
835
838
  if "websocket" in err.lower() or "connect" in err.lower() or "ws://" in err.lower():
836
- print(f'Browser is not running or CDP port {CDP_PORT} is unreachable. Run \'cdpilot launch\' first.', file=sys.stderr)
839
+ print(f"Browser is not running or CDP port {CDP_PORT} is unreachable. Run 'cdpilot launch' first.", file=sys.stderr)
837
840
  sys.exit(1)
838
841
  raise
839
842
 
@@ -1338,16 +1341,62 @@ async def cmd_html():
1338
1341
  print("[Output truncated at 1MB]")
1339
1342
 
1340
1343
 
1341
- 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
+ """
1342
1353
  if not output:
1343
1354
  output = f"{SCREENSHOT_DIR}/screenshot.png"
1344
1355
  ws, _ = get_page_ws()
1345
- r = await cdp_send(ws, [(1, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": True})])
1346
- 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", "")
1347
1394
  if b64:
1395
+ data = base64.b64decode(b64)
1348
1396
  with open(output, "wb") as f:
1349
- f.write(base64.b64decode(b64))
1350
- print(f"{output}")
1397
+ f.write(data)
1398
+ size_kb = len(data) / 1024
1399
+ print(f"{output} ({size_kb:.1f}KB)")
1351
1400
  else:
1352
1401
  print("Screenshot failed", file=sys.stderr)
1353
1402
 
@@ -2042,7 +2091,8 @@ def cmd_ext_remove(ext_id):
2042
2091
  async def cmd_new_tab(url='about:blank'):
2043
2092
  """Open a new tab."""
2044
2093
  import urllib.parse
2045
- 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)}')
2046
2096
  if data:
2047
2097
  print(f'New tab opened: {data.get("url", url)}')
2048
2098
  print(f' ID: {data.get("id", "?")}')
@@ -2778,6 +2828,751 @@ async def cmd_describe():
2778
2828
  print(page_text[:2000])
2779
2829
 
2780
2830
 
2831
+ # ─── Data Extraction & Observation Commands ───
2832
+
2833
+ async def cmd_extract(selector, output_format="text"):
2834
+ """Extract structured data from elements matching selector.
2835
+
2836
+ Returns text content, attributes, or full JSON structure.
2837
+ No LLM required — pure DOM extraction.
2838
+
2839
+ Usage:
2840
+ cdpilot extract "table tr" → text rows
2841
+ cdpilot extract ".product" --json → full JSON (tag, text, attrs, children)
2842
+ cdpilot extract "a" --attrs=href,title → specific attributes
2843
+ cdpilot extract "ul li" --list → clean list output
2844
+ """
2845
+ ws_url, _ = get_page_ws()
2846
+ safe_sel = json.dumps(selector)
2847
+
2848
+ if output_format == "json":
2849
+ js = f"""
2850
+ (function() {{
2851
+ var els = document.querySelectorAll({safe_sel});
2852
+ if (!els.length) return JSON.stringify([]);
2853
+ return JSON.stringify(Array.from(els).slice(0, 100).map(function(el) {{
2854
+ var attrs = {{}};
2855
+ for (var i = 0; i < el.attributes.length; i++) {{
2856
+ attrs[el.attributes[i].name] = el.attributes[i].value;
2857
+ }}
2858
+ return {{
2859
+ tag: el.tagName.toLowerCase(),
2860
+ text: (el.textContent || '').trim().substring(0, 500),
2861
+ attrs: attrs,
2862
+ value: el.value || null,
2863
+ href: el.href || null,
2864
+ src: el.src || null
2865
+ }};
2866
+ }}));
2867
+ }})()
2868
+ """
2869
+ elif output_format.startswith("attrs="):
2870
+ attr_names = output_format.split("=", 1)[1].split(",")
2871
+ attrs_js = ",".join(f'"{a}": el.getAttribute("{a}")' for a in attr_names)
2872
+ js = f"""
2873
+ (function() {{
2874
+ var els = document.querySelectorAll({safe_sel});
2875
+ if (!els.length) return JSON.stringify([]);
2876
+ return JSON.stringify(Array.from(els).slice(0, 100).map(function(el) {{
2877
+ return {{ {attrs_js} }};
2878
+ }}));
2879
+ }})()
2880
+ """
2881
+ elif output_format == "list":
2882
+ js = f"""
2883
+ (function() {{
2884
+ var els = document.querySelectorAll({safe_sel});
2885
+ if (!els.length) return '';
2886
+ return Array.from(els).slice(0, 200).map(function(el, i) {{
2887
+ return (i + 1) + '. ' + (el.textContent || '').trim().substring(0, 200);
2888
+ }}).join('\\n');
2889
+ }})()
2890
+ """
2891
+ else:
2892
+ # Default: text content, one per line
2893
+ js = f"""
2894
+ (function() {{
2895
+ var els = document.querySelectorAll({safe_sel});
2896
+ if (!els.length) return '';
2897
+ return Array.from(els).slice(0, 200).map(function(el) {{
2898
+ return (el.textContent || '').trim().substring(0, 300);
2899
+ }}).filter(function(t) {{ return t; }}).join('\\n');
2900
+ }})()
2901
+ """
2902
+
2903
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2904
+ result = r.get(1, {}).get("result", {}).get("value", "")
2905
+ if not result:
2906
+ print(f"No elements found: {selector}", file=sys.stderr)
2907
+ return
2908
+ if output_format in ("json",) or output_format.startswith("attrs="):
2909
+ # Pretty print JSON
2910
+ try:
2911
+ parsed = json.loads(result)
2912
+ print(json.dumps(parsed, indent=2, ensure_ascii=False))
2913
+ except (json.JSONDecodeError, TypeError):
2914
+ print(result)
2915
+ else:
2916
+ print(result)
2917
+
2918
+
2919
+ async def cmd_observe():
2920
+ """List all interactive elements on the page with actions.
2921
+
2922
+ Like Stagehand's observe() but deterministic — no LLM needed.
2923
+ Shows what you CAN DO on the current page.
2924
+ """
2925
+ ws_url, _ = get_page_ws()
2926
+ js = """
2927
+ (function() {
2928
+ var results = [];
2929
+ var els = document.querySelectorAll(
2930
+ 'a, button, input, textarea, select, [role=button], [role=link], ' +
2931
+ '[role=tab], [role=menuitem], [role=checkbox], [role=radio], ' +
2932
+ '[onclick], [tabindex]:not([tabindex="-1"])'
2933
+ );
2934
+ var seen = new Set();
2935
+ Array.from(els).forEach(function(el, i) {
2936
+ if (i >= 50) return;
2937
+ var rect = el.getBoundingClientRect();
2938
+ if (rect.width === 0 && rect.height === 0) return;
2939
+ var style = window.getComputedStyle(el);
2940
+ if (style.display === 'none' || style.visibility === 'hidden') return;
2941
+
2942
+ var tag = el.tagName.toLowerCase();
2943
+ var type = el.type || '';
2944
+ var role = el.getAttribute('role') || '';
2945
+ var text = (el.textContent || el.value || el.placeholder || '').trim().substring(0, 60);
2946
+ var href = el.href || '';
2947
+ var name = el.name || el.id || '';
2948
+
2949
+ // Determine action
2950
+ var action = 'click';
2951
+ if (tag === 'input' || tag === 'textarea') {
2952
+ if (type === 'checkbox' || type === 'radio') action = 'toggle';
2953
+ else if (type === 'submit') action = 'submit';
2954
+ else if (type === 'file') action = 'upload';
2955
+ else action = 'fill';
2956
+ } else if (tag === 'select') {
2957
+ action = 'select';
2958
+ } else if (tag === 'a') {
2959
+ action = 'navigate';
2960
+ }
2961
+
2962
+ // Build selector
2963
+ var sel = '';
2964
+ if (el.id) sel = '#' + el.id;
2965
+ else if (name) sel = tag + '[name=' + JSON.stringify(name) + ']';
2966
+ else if (type) sel = tag + '[type=' + type + ']';
2967
+ else sel = tag;
2968
+
2969
+ var key = action + ':' + sel + ':' + text;
2970
+ if (seen.has(key)) return;
2971
+ seen.add(key);
2972
+
2973
+ var line = action.toUpperCase() + ' ' + sel;
2974
+ if (text) line += ' "' + text + '"';
2975
+ if (href && action === 'navigate') line += ' → ' + href.substring(0, 80);
2976
+ results.push(line);
2977
+ });
2978
+ return results.join('\\n') || 'No interactive elements found';
2979
+ })()
2980
+ """
2981
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
2982
+ result = r.get(1, {}).get("result", {}).get("value", "No interactive elements found")
2983
+ print(f"=== What you can do on this page ===\n")
2984
+ print(result)
2985
+
2986
+
2987
+ # ─── Smart Commands (LLM-free intelligence) ───
2988
+
2989
+ async def cmd_smart_click(text):
2990
+ """Click element by visible text — fuzzy matching, no CSS selector needed.
2991
+
2992
+ Like Stagehand's act("Click login") but without LLM.
2993
+ Searches: button text, link text, aria-label, placeholder, title, value.
2994
+
2995
+ Usage:
2996
+ cdpilot smart-click "Login"
2997
+ cdpilot smart-click "Submit Order"
2998
+ cdpilot smart-click "Learn more"
2999
+ """
3000
+ ws_url, _ = get_page_ws()
3001
+ safe_text = json.dumps(text.lower())
3002
+ js = f"""
3003
+ (function() {{
3004
+ var search = {safe_text};
3005
+ var candidates = [];
3006
+
3007
+ // Score: exact > startsWith > includes > partial
3008
+ function score(str) {{
3009
+ if (!str) return 0;
3010
+ var s = str.toLowerCase().trim();
3011
+ if (s === search) return 100;
3012
+ if (s.startsWith(search)) return 80;
3013
+ if (s.includes(search)) return 60;
3014
+ // Partial word match
3015
+ var words = search.split(/\\s+/);
3016
+ var matched = words.filter(function(w) {{ return s.includes(w); }}).length;
3017
+ if (matched > 0) return 20 + (matched / words.length) * 30;
3018
+ return 0;
3019
+ }}
3020
+
3021
+ var els = document.querySelectorAll(
3022
+ 'a, button, input[type=submit], input[type=button], ' +
3023
+ '[role=button], [role=link], [role=tab], [role=menuitem], ' +
3024
+ 'summary, label, [onclick], [tabindex]'
3025
+ );
3026
+
3027
+ Array.from(els).forEach(function(el) {{
3028
+ var rect = el.getBoundingClientRect();
3029
+ if (rect.width === 0 && rect.height === 0) return;
3030
+ var style = window.getComputedStyle(el);
3031
+ if (style.display === 'none' || style.visibility === 'hidden') return;
3032
+
3033
+ var texts = [
3034
+ el.textContent || '',
3035
+ el.getAttribute('aria-label') || '',
3036
+ el.getAttribute('title') || '',
3037
+ el.getAttribute('placeholder') || '',
3038
+ el.value || '',
3039
+ el.getAttribute('alt') || ''
3040
+ ];
3041
+
3042
+ var bestScore = 0;
3043
+ var bestMatch = '';
3044
+ texts.forEach(function(t) {{
3045
+ var s = score(t);
3046
+ if (s > bestScore) {{ bestScore = s; bestMatch = t.trim().substring(0, 60); }}
3047
+ }});
3048
+
3049
+ if (bestScore > 0) {{
3050
+ candidates.push({{
3051
+ el: el,
3052
+ score: bestScore,
3053
+ match: bestMatch,
3054
+ tag: el.tagName.toLowerCase(),
3055
+ cx: rect.x + rect.width / 2,
3056
+ cy: rect.y + rect.height / 2
3057
+ }});
3058
+ }}
3059
+ }});
3060
+
3061
+ if (candidates.length === 0) return JSON.stringify({{found: false}});
3062
+
3063
+ candidates.sort(function(a, b) {{ return b.score - a.score; }});
3064
+ var best = candidates[0];
3065
+ best.el.scrollIntoView({{block: 'center'}});
3066
+ var rect = best.el.getBoundingClientRect();
3067
+ best.el.click();
3068
+ return JSON.stringify({{
3069
+ found: true,
3070
+ tag: best.tag,
3071
+ text: best.match,
3072
+ score: best.score,
3073
+ x: rect.x + rect.width / 2,
3074
+ y: rect.y + rect.height / 2,
3075
+ alternatives: candidates.slice(1, 4).map(function(c) {{
3076
+ return c.tag + ' "' + c.match + '" (score:' + c.score + ')';
3077
+ }})
3078
+ }});
3079
+ }})()
3080
+ """
3081
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3082
+ raw = r.get(1, {}).get("result", {}).get("value", "")
3083
+ try:
3084
+ data = json.loads(raw)
3085
+ except (json.JSONDecodeError, TypeError):
3086
+ print(f"Error parsing result", file=sys.stderr)
3087
+ return
3088
+
3089
+ if not data.get("found"):
3090
+ print(f'No element found matching: "{text}"', file=sys.stderr)
3091
+ sys.exit(1)
3092
+
3093
+ await _vfx_ripple(ws_url, data["x"], data["y"])
3094
+ print(f'Clicked: {data["tag"].upper()} "{data["text"]}" (score:{data["score"]})')
3095
+ if data.get("alternatives"):
3096
+ print(f' Also found: {", ".join(data["alternatives"])}')
3097
+
3098
+
3099
+ async def cmd_smart_fill(text, value):
3100
+ """Fill input by label/placeholder text — no CSS selector needed.
3101
+
3102
+ Finds input by: associated label, placeholder, aria-label, name, id match.
3103
+
3104
+ Usage:
3105
+ cdpilot smart-fill "Email" "test@example.com"
3106
+ cdpilot smart-fill "Password" "secret123"
3107
+ cdpilot smart-fill "Search" "cdpilot"
3108
+ """
3109
+ ws_url, _ = get_page_ws()
3110
+ safe_text = json.dumps(text.lower())
3111
+ safe_value = json.dumps(value)
3112
+ js = f"""
3113
+ (function() {{
3114
+ var search = {safe_text};
3115
+ var value = {safe_value};
3116
+ var candidates = [];
3117
+
3118
+ function score(str) {{
3119
+ if (!str) return 0;
3120
+ var s = str.toLowerCase().trim();
3121
+ if (s === search) return 100;
3122
+ if (s.startsWith(search)) return 80;
3123
+ if (s.includes(search)) return 60;
3124
+ return 0;
3125
+ }}
3126
+
3127
+ var inputs = document.querySelectorAll('input, textarea, select, [contenteditable=true]');
3128
+ Array.from(inputs).forEach(function(el) {{
3129
+ var rect = el.getBoundingClientRect();
3130
+ if (rect.width === 0 && rect.height === 0) return;
3131
+
3132
+ var scores = [];
3133
+ // Check placeholder
3134
+ scores.push(score(el.getAttribute('placeholder') || ''));
3135
+ // Check aria-label
3136
+ scores.push(score(el.getAttribute('aria-label') || ''));
3137
+ // Check name/id
3138
+ scores.push(score(el.name || ''));
3139
+ scores.push(score(el.id || ''));
3140
+ // Check associated label
3141
+ if (el.id) {{
3142
+ var label = document.querySelector('label[for="' + el.id + '"]');
3143
+ if (label) scores.push(score(label.textContent || ''));
3144
+ }}
3145
+ // Check parent label
3146
+ var parentLabel = el.closest('label');
3147
+ if (parentLabel) scores.push(score(parentLabel.textContent || ''));
3148
+ // Check preceding text node/label
3149
+ var prev = el.previousElementSibling;
3150
+ if (prev) scores.push(score(prev.textContent || ''));
3151
+
3152
+ var bestScore = Math.max.apply(null, scores);
3153
+ if (bestScore > 0) {{
3154
+ candidates.push({{el: el, score: bestScore, tag: el.tagName.toLowerCase(), type: el.type || ''}});
3155
+ }}
3156
+ }});
3157
+
3158
+ if (candidates.length === 0) return JSON.stringify({{found: false}});
3159
+
3160
+ candidates.sort(function(a, b) {{ return b.score - a.score; }});
3161
+ var best = candidates[0];
3162
+
3163
+ // React-compatible value setting
3164
+ var nativeSetter = Object.getOwnPropertyDescriptor(
3165
+ window.HTMLInputElement.prototype, 'value'
3166
+ ) || Object.getOwnPropertyDescriptor(
3167
+ window.HTMLTextAreaElement.prototype, 'value'
3168
+ );
3169
+ if (nativeSetter && nativeSetter.set) {{
3170
+ nativeSetter.set.call(best.el, value);
3171
+ }} else {{
3172
+ best.el.value = value;
3173
+ }}
3174
+ best.el.dispatchEvent(new Event('input', {{bubbles: true}}));
3175
+ best.el.dispatchEvent(new Event('change', {{bubbles: true}}));
3176
+
3177
+ return JSON.stringify({{
3178
+ found: true,
3179
+ tag: best.tag,
3180
+ type: best.type,
3181
+ score: best.score,
3182
+ placeholder: best.el.getAttribute('placeholder') || '',
3183
+ name: best.el.name || best.el.id || ''
3184
+ }});
3185
+ }})()
3186
+ """
3187
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3188
+ raw = r.get(1, {}).get("result", {}).get("value", "")
3189
+ try:
3190
+ data = json.loads(raw)
3191
+ except (json.JSONDecodeError, TypeError):
3192
+ print(f"Error parsing result", file=sys.stderr)
3193
+ return
3194
+
3195
+ if not data.get("found"):
3196
+ print(f'No input found matching: "{text}"', file=sys.stderr)
3197
+ sys.exit(1)
3198
+
3199
+ ident = data.get("placeholder") or data.get("name") or data["tag"]
3200
+ print(f'Filled: {data["tag"].upper()}[{data["type"]}] "{ident}" = {value} (score:{data["score"]})')
3201
+
3202
+
3203
+ async def cmd_smart_select(text, option_text):
3204
+ """Select dropdown option by label text — no CSS selector needed.
3205
+
3206
+ Usage:
3207
+ cdpilot smart-select "Country" "Turkey"
3208
+ cdpilot smart-select "Size" "Large"
3209
+ """
3210
+ ws_url, _ = get_page_ws()
3211
+ safe_text = json.dumps(text.lower())
3212
+ safe_option = json.dumps(option_text.lower())
3213
+ js = f"""
3214
+ (function() {{
3215
+ var search = {safe_text};
3216
+ var optSearch = {safe_option};
3217
+ var selects = document.querySelectorAll('select');
3218
+ var best = null;
3219
+ var bestScore = 0;
3220
+
3221
+ Array.from(selects).forEach(function(sel) {{
3222
+ var texts = [
3223
+ sel.getAttribute('aria-label') || '',
3224
+ sel.name || '', sel.id || ''
3225
+ ];
3226
+ if (sel.id) {{
3227
+ var label = document.querySelector('label[for="' + sel.id + '"]');
3228
+ if (label) texts.push(label.textContent || '');
3229
+ }}
3230
+ var parent = sel.closest('label');
3231
+ if (parent) texts.push(parent.textContent || '');
3232
+
3233
+ texts.forEach(function(t) {{
3234
+ var s = t.toLowerCase().trim();
3235
+ var sc = s === search ? 100 : s.includes(search) ? 60 : 0;
3236
+ if (sc > bestScore) {{ bestScore = sc; best = sel; }}
3237
+ }});
3238
+ }});
3239
+
3240
+ if (!best) return JSON.stringify({{found: false}});
3241
+
3242
+ // Find matching option
3243
+ var options = Array.from(best.options);
3244
+ var match = options.find(function(o) {{
3245
+ return o.text.toLowerCase().trim() === optSearch;
3246
+ }}) || options.find(function(o) {{
3247
+ return o.text.toLowerCase().includes(optSearch);
3248
+ }});
3249
+
3250
+ if (!match) return JSON.stringify({{found: true, optionFound: false, available: options.map(function(o) {{ return o.text; }}).slice(0, 10)}});
3251
+
3252
+ best.value = match.value;
3253
+ best.dispatchEvent(new Event('change', {{bubbles: true}}));
3254
+ return JSON.stringify({{found: true, optionFound: true, selected: match.text, value: match.value}});
3255
+ }})()
3256
+ """
3257
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3258
+ raw = r.get(1, {}).get("result", {}).get("value", "")
3259
+ try:
3260
+ data = json.loads(raw)
3261
+ except (json.JSONDecodeError, TypeError):
3262
+ print("Error parsing result", file=sys.stderr)
3263
+ return
3264
+
3265
+ if not data.get("found"):
3266
+ print(f'No select found matching: "{text}"', file=sys.stderr)
3267
+ sys.exit(1)
3268
+ if not data.get("optionFound"):
3269
+ print(f'Option "{option_text}" not found. Available: {", ".join(data.get("available", []))}', file=sys.stderr)
3270
+ sys.exit(1)
3271
+ print(f'Selected: "{data["selected"]}" (value={data["value"]})')
3272
+
3273
+
3274
+ async def cmd_run_script(script_path):
3275
+ """Run a .cdp script file — sequential commands, one per line.
3276
+
3277
+ Script format (plain text):
3278
+ go https://example.com
3279
+ wait-for h1
3280
+ assert h1 "Example Domain"
3281
+ click a
3282
+ shot /tmp/result.png
3283
+
3284
+ Lines starting with # are comments. Empty lines are skipped.
3285
+ """
3286
+ if not os.path.exists(script_path):
3287
+ print(f"Script not found: {script_path}", file=sys.stderr)
3288
+ sys.exit(1)
3289
+
3290
+ with open(script_path) as f:
3291
+ lines = f.readlines()
3292
+
3293
+ import shlex
3294
+ passed = 0
3295
+ failed = 0
3296
+ for line_num, line in enumerate(lines, 1):
3297
+ line = line.strip()
3298
+ if not line or line.startswith("#"):
3299
+ continue
3300
+ try:
3301
+ parts = shlex.split(line)
3302
+ except ValueError:
3303
+ parts = line.split()
3304
+ cmd_name = parts[0]
3305
+ cmd_args = parts[1:]
3306
+
3307
+ print(f"[{line_num}] {line}")
3308
+ try:
3309
+ result = subprocess.run(
3310
+ [sys.executable, __file__] + parts,
3311
+ capture_output=True, text=True, timeout=30,
3312
+ env={**os.environ, "CDPILOT_MCP_SESSION": "1"}
3313
+ )
3314
+ output = result.stdout.strip()
3315
+ if output:
3316
+ for out_line in output.split("\n"):
3317
+ print(f" {out_line}")
3318
+ if result.returncode != 0:
3319
+ err = result.stderr.strip()
3320
+ if err:
3321
+ print(f" ERROR: {err}")
3322
+ failed += 1
3323
+ else:
3324
+ passed += 1
3325
+ except subprocess.TimeoutExpired:
3326
+ print(f" TIMEOUT")
3327
+ failed += 1
3328
+
3329
+ print(f"\n{'─' * 40}")
3330
+ print(f"Script: {script_path}")
3331
+ print(f"Result: {passed} passed, {failed} failed, {passed + failed} total")
3332
+
3333
+
3334
+ # ─── Testing Commands ───
3335
+
3336
+ async def cmd_assert(selector, expected_text=None, check_visible=True):
3337
+ """Assert element exists, optionally check text content and visibility."""
3338
+ ws_url, _ = get_page_ws()
3339
+ visible_check = ""
3340
+ if check_visible:
3341
+ visible_check = """
3342
+ var rect = el.getBoundingClientRect();
3343
+ var style = window.getComputedStyle(el);
3344
+ if (style.display === 'none' || style.visibility === 'hidden' ||
3345
+ style.opacity === '0' || (rect.width === 0 && rect.height === 0)) {
3346
+ return 'FAIL: Element found but not visible: ' + sel;
3347
+ }"""
3348
+ text_check = ""
3349
+ if expected_text:
3350
+ safe_text = json.dumps(expected_text)
3351
+ text_check = f"""
3352
+ var actual = el.textContent || el.value || '';
3353
+ if (actual.indexOf({safe_text}) === -1) {{
3354
+ return 'FAIL: Expected text ' + {safe_text} + ' not found in: ' + actual.substring(0, 100);
3355
+ }}"""
3356
+ safe_sel = json.dumps(selector)
3357
+ js = f"""
3358
+ (function() {{
3359
+ var sel = {safe_sel};
3360
+ var el = document.querySelector(sel);
3361
+ if (!el) return 'FAIL: Element not found: ' + sel;
3362
+ {visible_check}
3363
+ {text_check}
3364
+ var tag = el.tagName.toLowerCase();
3365
+ var txt = (el.textContent || '').substring(0, 60).trim();
3366
+ return 'PASS: ' + tag + (txt ? ' "' + txt + '"' : '') + ' (' + sel + ')';
3367
+ }})()
3368
+ """
3369
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3370
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3371
+ print(result)
3372
+
3373
+
3374
+ async def cmd_wait_for(selector, timeout_ms=5000):
3375
+ """Wait for element to appear in DOM, up to timeout."""
3376
+ ws_url, _ = get_page_ws()
3377
+ safe_sel = json.dumps(selector)
3378
+ js = f"""
3379
+ (function() {{
3380
+ return new Promise(function(resolve) {{
3381
+ var sel = {safe_sel};
3382
+ var el = document.querySelector(sel);
3383
+ if (el) return resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"');
3384
+ var obs = new MutationObserver(function() {{
3385
+ var el = document.querySelector(sel);
3386
+ if (el) {{ obs.disconnect(); resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"'); }}
3387
+ }});
3388
+ obs.observe(document.body, {{childList: true, subtree: true}});
3389
+ setTimeout(function() {{ obs.disconnect(); resolve('TIMEOUT: ' + sel + ' not found after {timeout_ms}ms'); }}, {timeout_ms});
3390
+ }});
3391
+ }})()
3392
+ """
3393
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})], timeout=max(15, timeout_ms // 1000 + 5))
3394
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3395
+ print(result)
3396
+
3397
+
3398
+ async def cmd_check(checks_json=None):
3399
+ """Run batch assertions. Input: JSON array of {selector, text?} objects."""
3400
+ ws_url, _ = get_page_ws()
3401
+ if checks_json is None:
3402
+ raw = sys.stdin.read().strip()
3403
+ else:
3404
+ raw = checks_json
3405
+ try:
3406
+ checks = json.loads(raw)
3407
+ except (json.JSONDecodeError, TypeError):
3408
+ print("Error: Invalid JSON. Expected: [{\"selector\": \"...\", \"text\": \"...\"}]")
3409
+ sys.exit(1)
3410
+
3411
+ passed = 0
3412
+ failed = 0
3413
+ results = []
3414
+ for i, check in enumerate(checks, 1):
3415
+ sel = check.get("selector", "")
3416
+ text = check.get("text")
3417
+ safe_sel = json.dumps(sel)
3418
+ text_check = ""
3419
+ if text:
3420
+ safe_text = json.dumps(text)
3421
+ text_check = f"""
3422
+ var actual = el.textContent || el.value || '';
3423
+ if (actual.indexOf({safe_text}) === -1) {{
3424
+ return 'FAIL: Expected ' + {safe_text} + ' in: ' + actual.substring(0, 80);
3425
+ }}"""
3426
+ js = f"""
3427
+ (function() {{
3428
+ var el = document.querySelector({safe_sel});
3429
+ if (!el) return 'FAIL: Not found: ' + {safe_sel};
3430
+ {text_check}
3431
+ return 'PASS: ' + el.tagName + ' (' + {safe_sel} + ')';
3432
+ }})()
3433
+ """
3434
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3435
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3436
+ if result.startswith("PASS"):
3437
+ passed += 1
3438
+ else:
3439
+ failed += 1
3440
+ results.append(f" {i}. {result}")
3441
+
3442
+ print(f"Test Report: {passed} passed, {failed} failed")
3443
+ print("─" * 40)
3444
+ for line in results:
3445
+ print(line)
3446
+
3447
+
3448
+ async def cmd_assert_url(expected_url):
3449
+ """Assert current page URL contains the expected substring."""
3450
+ ws_url, _ = get_page_ws()
3451
+ safe_expected = json.dumps(expected_url)
3452
+ js = f"""(function() {{
3453
+ var href = window.location.href;
3454
+ var expected = {safe_expected};
3455
+ if (href.indexOf(expected) !== -1) return 'PASS: URL ' + href + ' contains \"' + expected + '\"';
3456
+ return 'FAIL: URL ' + href + ' does not contain \"' + expected + '\"';
3457
+ }})()"""
3458
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3459
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3460
+ print(result)
3461
+
3462
+
3463
+ async def cmd_assert_title(expected_title):
3464
+ """Assert page title contains the expected substring."""
3465
+ ws_url, _ = get_page_ws()
3466
+ safe_expected = json.dumps(expected_title)
3467
+ js = f"""(function() {{
3468
+ var title = document.title;
3469
+ var expected = {safe_expected};
3470
+ if (title.indexOf(expected) !== -1) return 'PASS: Title \"' + title + '\" contains \"' + expected + '\"';
3471
+ return 'FAIL: Title \"' + title + '\" does not contain \"' + expected + '\"';
3472
+ }})()"""
3473
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3474
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3475
+ print(result)
3476
+
3477
+
3478
+ async def cmd_assert_count(selector, expected_count):
3479
+ """Assert the number of elements matching a CSS selector equals expected_count."""
3480
+ ws_url, _ = get_page_ws()
3481
+ safe_sel = json.dumps(selector)
3482
+ exp = int(expected_count)
3483
+ js = f"""(function() {{
3484
+ var count = document.querySelectorAll({safe_sel}).length;
3485
+ var exp = {exp};
3486
+ if (count === exp) return 'PASS: Found ' + count + ' element(s) matching {safe_sel} (expected ' + exp + ')';
3487
+ return 'FAIL: Expected ' + exp + ' \"{selector}\" but found ' + count;
3488
+ }})()"""
3489
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3490
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3491
+ print(result)
3492
+
3493
+
3494
+ async def cmd_assert_value(selector, expected_value):
3495
+ """Assert an input/textarea/select element's value equals expected_value."""
3496
+ ws_url, _ = get_page_ws()
3497
+ safe_sel = json.dumps(selector)
3498
+ safe_expected = json.dumps(expected_value)
3499
+ js = f"""(function() {{
3500
+ var el = document.querySelector({safe_sel});
3501
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
3502
+ var val = el.value;
3503
+ var expected = {safe_expected};
3504
+ if (val === expected) return 'PASS: Value matches \"' + expected + '\"';
3505
+ return 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
3506
+ }})()"""
3507
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3508
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3509
+ print(result)
3510
+
3511
+
3512
+ async def cmd_assert_attr(selector, attr, expected):
3513
+ """Assert element attribute value contains expected substring."""
3514
+ ws_url, _ = get_page_ws()
3515
+ safe_sel = json.dumps(selector)
3516
+ safe_attr = json.dumps(attr)
3517
+ safe_expected = json.dumps(expected)
3518
+ js = f"""(function() {{
3519
+ var el = document.querySelector({safe_sel});
3520
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
3521
+ var val = el.getAttribute({safe_attr}) || '';
3522
+ var expected = {safe_expected};
3523
+ if (val.indexOf(expected) !== -1) return 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"';
3524
+ return 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
3525
+ }})()"""
3526
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3527
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3528
+ print(result)
3529
+
3530
+
3531
+ async def cmd_assert_visible(selector, should_be_visible=True):
3532
+ """Assert element is visible (or hidden). should_be_visible=True checks for visible, False for hidden."""
3533
+ ws_url, _ = get_page_ws()
3534
+ safe_sel = json.dumps(selector)
3535
+ expect_label = "visible" if should_be_visible else "hidden"
3536
+ opposite_label = "hidden" if should_be_visible else "visible"
3537
+ js = f"""(function() {{
3538
+ var el = document.querySelector({safe_sel});
3539
+ if (!el) return 'FAIL: Element not found: ' + {safe_sel};
3540
+ var style = window.getComputedStyle(el);
3541
+ var rect = el.getBoundingClientRect();
3542
+ var isVisible = (
3543
+ style.display !== 'none' &&
3544
+ style.visibility !== 'hidden' &&
3545
+ style.opacity !== '0' &&
3546
+ (rect.width > 0 || rect.height > 0)
3547
+ );
3548
+ var expectVisible = {str(should_be_visible).lower()};
3549
+ if (isVisible === expectVisible) return 'PASS: ' + {safe_sel} + ' is {expect_label}';
3550
+ return 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
3551
+ }})()"""
3552
+ r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
3553
+ result = r.get(1, {}).get("result", {}).get("value", "ERROR")
3554
+ print(result)
3555
+
3556
+
3557
+ async def cmd_screenshot_diff(path1, path2):
3558
+ """Compare two screenshot files byte-by-byte. No CDP required."""
3559
+ for path in (path1, path2):
3560
+ if not os.path.exists(path):
3561
+ print(f"ERROR: File not found: {path}")
3562
+ return
3563
+ size1 = os.path.getsize(path1)
3564
+ size2 = os.path.getsize(path2)
3565
+ with open(path1, "rb") as f1, open(path2, "rb") as f2:
3566
+ data1 = f1.read()
3567
+ data2 = f2.read()
3568
+ if data1 == data2:
3569
+ print("MATCH: Files are identical")
3570
+ else:
3571
+ kb1 = size1 / 1024
3572
+ kb2 = size2 / 1024
3573
+ print(f"DIFF: Files differ ({os.path.basename(path1)}: {kb1:.1f}KB, {os.path.basename(path2)}: {kb2:.1f}KB)")
3574
+
3575
+
2781
3576
  async def cmd_a11y_snapshot():
2782
3577
  """Output a compact accessibility snapshot for AI agent navigation.
2783
3578
 
@@ -3338,36 +4133,68 @@ class MCPServer:
3338
4133
 
3339
4134
  def _register_tools(self):
3340
4135
  return [
3341
- {"name": "browser_navigate", "description": "Navigate to a URL",
3342
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to"}}, "required": ["url"]}},
3343
- {"name": "browser_screenshot", "description": "Take a screenshot of the current page",
3344
- "inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output filename", "default": "screenshot.png"}}}},
3345
- {"name": "browser_click", "description": "Click an element by CSS selector",
3346
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}}, "required": ["selector"]}},
3347
- {"name": "browser_type", "description": "Type text into an input element",
3348
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "text": {"type": "string", "description": "Text to type"}}, "required": ["selector", "text"]}},
3349
- {"name": "browser_content", "description": "Get text content of the current page",
4136
+ {"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.",
4137
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "Full URL to navigate to (must include https://)"}}, "required": ["url"]}},
4138
+ {"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.",
4139
+ "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"}}}},
4140
+ {"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.",
4141
+ "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"]}},
4142
+ {"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.",
4143
+ "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"]}},
4144
+ {"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.",
4145
+ "inputSchema": {"type": "object", "properties": {}}},
4146
+ {"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.",
3350
4147
  "inputSchema": {"type": "object", "properties": {}}},
3351
- {"name": "browser_html", "description": "Get HTML source of the current page",
4148
+ {"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.",
4149
+ "inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression to evaluate (e.g. 'document.title', 'document.querySelectorAll(\"a\").length')"}}, "required": ["expression"]}},
4150
+ {"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.",
3352
4151
  "inputSchema": {"type": "object", "properties": {}}},
3353
- {"name": "browser_eval", "description": "Execute JavaScript in the browser",
3354
- "inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression"}}, "required": ["expression"]}},
3355
- {"name": "browser_tabs", "description": "List all open browser tabs",
4152
+ {"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.",
4153
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while capturing console output"}}}},
4154
+ {"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.",
4155
+ "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while monitoring network traffic"}}}},
4156
+ {"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.",
4157
+ "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"}}}},
4158
+ {"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.",
4159
+ "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"]}},
4160
+ {"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.",
3356
4161
  "inputSchema": {"type": "object", "properties": {}}},
3357
- {"name": "browser_console", "description": "Get console logs from the browser",
3358
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and capture logs"}}}},
3359
- {"name": "browser_network", "description": "Monitor network requests",
3360
- "inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and monitor"}}}},
3361
- {"name": "browser_a11y", "description": "Get accessibility tree of the current page",
3362
- "inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "default": "full"}}}},
3363
- {"name": "browser_fill", "description": "Set input value (React-compatible)",
3364
- "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "value": {"type": "string", "description": "Value to set"}}, "required": ["selector", "value"]}},
3365
- {"name": "browser_launch", "description": "Launch browser with CDP enabled",
4162
+ {"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.",
3366
4163
  "inputSchema": {"type": "object", "properties": {}}},
3367
- {"name": "browser_close", "description": "Close the active tab",
4164
+ {"name": "browser_extract", "description": "Extract structured data from elements matching a CSS selector. No LLM needed — pure DOM extraction. Returns text (default), JSON (with tag, text, attrs, href, src), specific attributes, or clean list. Use for scraping tables, lists, links, form values. Limit: 100 elements for JSON, 200 for text.",
4165
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to match elements (e.g. 'table tr', '.product', 'a')"}, "format": {"type": "string", "enum": ["text", "json", "list"], "description": "Output format: 'text' (one per line), 'json' (full structure with attrs), 'list' (numbered)", "default": "text"}}, "required": ["selector"]}},
4166
+ {"name": "browser_observe", "description": "List all interactive elements on the current page with their available actions (CLICK, FILL, NAVIGATE, TOGGLE, SELECT, SUBMIT, UPLOAD). Like Stagehand observe() but deterministic — no LLM needed. Shows what you CAN DO on the page. Use this to understand page structure before acting.",
3368
4167
  "inputSchema": {"type": "object", "properties": {}}},
3369
- {"name": "browser_describe", "description": "Get full page description with accessibility snapshot, screenshot, and text content. Use when a11y-snapshot alone is insufficient (canvas, WebGL, visual verification).",
4168
+ {"name": "browser_smart_click", "description": "Click an element by its visible text — no CSS selector needed. Uses fuzzy matching across text content, aria-label, title, placeholder, and value. Returns match score and alternatives. Like Stagehand act('Click login') but without LLM cost. Use when you know WHAT to click but not the exact selector.",
4169
+ "inputSchema": {"type": "object", "properties": {"text": {"type": "string", "description": "Visible text of the element to click (e.g. 'Login', 'Submit Order', 'Learn more')"}}, "required": ["text"]}},
4170
+ {"name": "browser_smart_fill", "description": "Fill an input by its label or placeholder text — no CSS selector needed. Finds input by associated label, placeholder, aria-label, name, or id. React-compatible value setting. Use when you know WHAT field to fill but not the exact selector.",
4171
+ "inputSchema": {"type": "object", "properties": {"label": {"type": "string", "description": "Label or placeholder text of the input (e.g. 'Email', 'Password', 'Search')"}, "value": {"type": "string", "description": "Value to fill in the input"}}, "required": ["label", "value"]}},
4172
+ {"name": "browser_smart_select", "description": "Select a dropdown option by label and option text — no CSS selector needed. Finds the select element by label/name, then selects the matching option. Use for dropdown interactions without knowing selectors.",
4173
+ "inputSchema": {"type": "object", "properties": {"label": {"type": "string", "description": "Label text of the select dropdown"}, "option": {"type": "string", "description": "Text of the option to select"}}, "required": ["label", "option"]}},
4174
+ {"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.",
3370
4175
  "inputSchema": {"type": "object", "properties": {}}},
4176
+ {"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.",
4177
+ "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"]}},
4178
+ {"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.",
4179
+ "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"]}},
4180
+ {"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.",
4181
+ "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"]}},
4182
+ {"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.",
4183
+ "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"]}},
4184
+ {"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.",
4185
+ "inputSchema": {"type": "object", "properties": {"expected_title": {"type": "string", "description": "Expected substring to find in the page title (e.g. 'Dashboard', 'Login')"}}, "required": ["expected_title"]}},
4186
+ {"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.",
4187
+ "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"]}},
4188
+ {"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.",
4189
+ "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"]}},
4190
+ {"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.",
4191
+ "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"]}},
4192
+ {"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.",
4193
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to check for visibility"}}, "required": ["selector"]}},
4194
+ {"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.",
4195
+ "inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
4196
+ {"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.",
4197
+ "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"]}},
3371
4198
  ]
3372
4199
 
3373
4200
  def _handle_request(self, request):
@@ -3405,7 +4232,7 @@ class MCPServer:
3405
4232
  import io, subprocess
3406
4233
  tool_map = {
3407
4234
  "browser_navigate": lambda a: ["go", a.get("url", "")],
3408
- "browser_screenshot": lambda a: ["shot"] + ([self._safe_filename(a["filename"])] if a.get("filename") else []),
4235
+ "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 []),
3409
4236
  "browser_click": lambda a: ["click", a.get("selector", "")],
3410
4237
  "browser_type": lambda a: ["type", a.get("selector", ""), a.get("text", "")],
3411
4238
  "browser_content": lambda a: ["content"],
@@ -3418,16 +4245,34 @@ class MCPServer:
3418
4245
  "browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
3419
4246
  "browser_launch": lambda a: ["launch"],
3420
4247
  "browser_close": lambda a: ["close"],
4248
+ "browser_extract": lambda a: ["extract", a.get("selector", "")] + ([f"--{a['format']}"] if a.get("format") and a["format"] != "text" else []),
4249
+ "browser_observe": lambda a: ["observe"],
4250
+ "browser_smart_click": lambda a: ["smart-click", a.get("text", "")],
4251
+ "browser_smart_fill": lambda a: ["smart-fill", a.get("label", ""), a.get("value", "")],
4252
+ "browser_smart_select": lambda a: ["smart-select", a.get("label", ""), a.get("option", "")],
3421
4253
  "browser_describe": lambda a: ["describe"],
4254
+ "browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
4255
+ "browser_wait_for": lambda a: ["wait-for", a.get("selector", "")] + ([str(a["timeout"])] if a.get("timeout") else []),
4256
+ "browser_check": lambda a: ["check", json.dumps(a.get("checks", []))],
4257
+ "browser_assert_url": lambda a: ["assert-url", a.get("expected_url", "")],
4258
+ "browser_assert_title": lambda a: ["assert-title", a.get("expected_title", "")],
4259
+ "browser_assert_count": lambda a: ["assert-count", a.get("selector", ""), str(a.get("expected_count", 0))],
4260
+ "browser_assert_value": lambda a: ["assert-value", a.get("selector", ""), a.get("expected_value", "")],
4261
+ "browser_assert_attr": lambda a: ["assert-attr", a.get("selector", ""), a.get("attr", ""), a.get("expected", "")],
4262
+ "browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
4263
+ "browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
4264
+ "browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
3422
4265
  }
3423
4266
  if tool_name not in tool_map:
3424
4267
  return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
3425
4268
 
3426
4269
  cli_args = [a for a in tool_map[tool_name](args) if a]
3427
4270
  try:
4271
+ env = os.environ.copy()
4272
+ env["CDPILOT_MCP_SESSION"] = "1"
3428
4273
  result = subprocess.run(
3429
4274
  [sys.executable, __file__] + cli_args,
3430
- capture_output=True, text=True, timeout=30
4275
+ capture_output=True, text=True, timeout=30, env=env
3431
4276
  )
3432
4277
  output = result.stdout.strip()
3433
4278
  errors = result.stderr.strip()
@@ -3536,7 +4381,12 @@ if __name__ == "__main__":
3536
4381
  "go": lambda: (require_args(1, "go <url>"), cmd_go(args[0]))[1] if not args else cmd_go(args[0]),
3537
4382
  "content": cmd_content,
3538
4383
  "html": cmd_html,
3539
- "shot": lambda: cmd_shot(args[0] if args else None),
4384
+ "shot": lambda: cmd_shot(
4385
+ output=next((a for a in args if not a.startswith("--")), None),
4386
+ quality=next((a.split("=")[1] for a in args if a.startswith("--quality=")), None),
4387
+ element=next((a.split("=")[1] for a in args if a.startswith("--element=")), None),
4388
+ fmt=next((a.split("=")[1] for a in args if a.startswith("--format=")), None),
4389
+ ),
3540
4390
  "shot-annotated": lambda: cmd_shot_annotated(args[0] if args else None),
3541
4391
  "batch": cmd_batch,
3542
4392
  "eval": lambda: (require_args(1, "eval <js>"), None)[1] if not args else cmd_eval(" ".join(args)),
@@ -3564,6 +4414,23 @@ if __name__ == "__main__":
3564
4414
  'a11y': lambda: cmd_a11y(' '.join(args)),
3565
4415
  'a11y-snapshot': cmd_a11y_snapshot,
3566
4416
  'describe': cmd_describe,
4417
+ 'extract': lambda: (require_args(1, 'extract <selector> [--json|--list|--attrs=href,title]'), None)[1] if not args else cmd_extract(args[0], next((a.lstrip('-') for a in args[1:] if a.startswith('--')), "text")),
4418
+ 'observe': cmd_observe,
4419
+ 'run': lambda: (require_args(1, 'run <script.cdp>'), None)[1] if not args else cmd_run_script(args[0]),
4420
+ 'smart-click': lambda: (require_args(1, 'smart-click <text>'), None)[1] if not args else cmd_smart_click(" ".join(args)),
4421
+ 'smart-fill': lambda: (require_args(2, 'smart-fill <label> <value>'), None)[1] if len(args) < 2 else cmd_smart_fill(args[0], " ".join(args[1:])),
4422
+ 'smart-select': lambda: (require_args(2, 'smart-select <label> <option>'), None)[1] if len(args) < 2 else cmd_smart_select(args[0], " ".join(args[1:])),
4423
+ '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),
4424
+ '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),
4425
+ 'check': lambda: cmd_check(args[0] if args else None),
4426
+ 'assert-url': lambda: (require_args(1, 'assert-url <expected>'), None)[1] if not args else cmd_assert_url(args[0]),
4427
+ 'assert-title': lambda: (require_args(1, 'assert-title <expected>'), None)[1] if not args else cmd_assert_title(args[0]),
4428
+ '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])),
4429
+ 'assert-value': lambda: (require_args(2, 'assert-value <selector> <value>'), None)[1] if len(args) < 2 else cmd_assert_value(args[0], args[1]),
4430
+ '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]),
4431
+ 'assert-visible': lambda: (require_args(1, 'assert-visible <selector>'), None)[1] if not args else cmd_assert_visible(args[0], True),
4432
+ 'assert-hidden': lambda: (require_args(1, 'assert-hidden <selector>'), None)[1] if not args else cmd_assert_visible(args[0], False),
4433
+ 'screenshot-diff': lambda: (require_args(2, 'screenshot-diff <path1> <path2>'), None)[1] if len(args) < 2 else cmd_screenshot_diff(args[0], args[1]),
3567
4434
  'click-ref': lambda: (require_args(1, 'click-ref <@N>'), None)[1] if not args else cmd_click_ref(args[0]),
3568
4435
  'hover': lambda: (require_args(1, 'hover <selector>'), None)[1] if not args else cmd_hover(args[0]),
3569
4436
  'dblclick': lambda: (require_args(1, 'dblclick <selector>'), None)[1] if not args else cmd_dblclick(args[0]),
@@ -3582,7 +4449,7 @@ if __name__ == "__main__":
3582
4449
  # Commands that do not require the visual indicator / input blocker
3583
4450
  NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
3584
4451
  'dialog', 'download', 'throttle', 'permission', 'intercept',
3585
- 'batch'}
4452
+ 'batch', 'screenshot-diff', 'run'}
3586
4453
  # Clean up idle sessions before running any command
3587
4454
  _cleanup_idle_sessions()
3588
4455