From bdf62d0f1cd9d2e56f17ee6ed9f6e5ec30fd0ceb Mon Sep 17 00:00:00 2001 From: cexll Date: Thu, 8 Jan 2026 11:33:19 +0800 Subject: [PATCH] add browser skill --- skills/browser/SKILL.md | 73 +++++++++++++++++++++ skills/browser/browser.zip | Bin 0 -> 5541 bytes skills/browser/package-lock.json | 33 ++++++++++ skills/browser/package.json | 5 ++ skills/browser/scripts/eval.cjs | 62 ++++++++++++++++++ skills/browser/scripts/nav.cjs | 70 +++++++++++++++++++++ skills/browser/scripts/pick.cjs | 87 ++++++++++++++++++++++++++ skills/browser/scripts/screenshot.cjs | 54 ++++++++++++++++ skills/browser/scripts/start.cjs | 35 +++++++++++ 9 files changed, 419 insertions(+) create mode 100644 skills/browser/SKILL.md create mode 100644 skills/browser/browser.zip create mode 100644 skills/browser/package-lock.json create mode 100644 skills/browser/package.json create mode 100755 skills/browser/scripts/eval.cjs create mode 100755 skills/browser/scripts/nav.cjs create mode 100755 skills/browser/scripts/pick.cjs create mode 100755 skills/browser/scripts/screenshot.cjs create mode 100755 skills/browser/scripts/start.cjs diff --git a/skills/browser/SKILL.md b/skills/browser/SKILL.md new file mode 100644 index 0000000..3c44c06 --- /dev/null +++ b/skills/browser/SKILL.md @@ -0,0 +1,73 @@ +--- +name: browser +description: This skill should be used for browser automation tasks using Chrome DevTools Protocol (CDP). Triggers when users need to launch Chrome with remote debugging, navigate pages, execute JavaScript in browser context, capture screenshots, or interactively select DOM elements. No MCP server required. +--- + +# Browser Automation + +Minimal Chrome DevTools Protocol (CDP) helpers for browser automation without MCP server setup. + +## Setup + +Install dependencies before first use: + +```bash +npm install --prefix ~/.claude/skills/browser/browser ws +``` + +## Scripts + +All scripts connect to Chrome on `localhost:9222`. + +### start.js - Launch Chrome + +```bash +scripts/start.js # Fresh profile +scripts/start.js --profile # Use persistent profile (keeps cookies/auth) +``` + +### nav.js - Navigate + +```bash +scripts/nav.js https://example.com # Navigate current tab +scripts/nav.js https://example.com --new # Open in new tab +``` + +### eval.js - Execute JavaScript + +```bash +scripts/eval.js 'document.title' +scripts/eval.js '(() => { const x = 1; return x + 1; })()' +``` + +Use single expressions or IIFE for multiple statements. + +### screenshot.js - Capture Screenshot + +```bash +scripts/screenshot.js +``` + +Returns `{ path, filename }` of saved PNG in temp directory. + +### pick.js - Visual Element Picker + +```bash +scripts/pick.js "Click the submit button" +``` + +Returns element metadata: tag, id, classes, text, href, selector, rect. + +## Workflow + +1. Launch Chrome: `scripts/start.js --profile` for authenticated sessions +2. Navigate: `scripts/nav.js ` +3. Inspect: `scripts/eval.js 'document.querySelector(...)'` +4. Capture: `scripts/screenshot.js` or `scripts/pick.js` +5. Return gathered data + +## Key Points + +- All operations run locally - credentials never leave the machine +- Use `--profile` flag to preserve cookies and auth tokens +- Scripts return structured JSON for agent consumption diff --git a/skills/browser/browser.zip b/skills/browser/browser.zip new file mode 100644 index 0000000000000000000000000000000000000000..0efcd6319ba4375b433872a393f0b9633d07378d GIT binary patch literal 5541 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 literal 0 HcmV?d00001 diff --git a/skills/browser/package-lock.json b/skills/browser/package-lock.json new file mode 100644 index 0000000..81393d4 --- /dev/null +++ b/skills/browser/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "browser", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "ws": "^8.18.3" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/skills/browser/package.json b/skills/browser/package.json new file mode 100644 index 0000000..31fef03 --- /dev/null +++ b/skills/browser/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "ws": "^8.18.3" + } +} diff --git a/skills/browser/scripts/eval.cjs b/skills/browser/scripts/eval.cjs new file mode 100755 index 0000000..e3c991b --- /dev/null +++ b/skills/browser/scripts/eval.cjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// Execute JavaScript in the active browser tab +const http = require('http'); +const WebSocket = require('ws'); + +const code = process.argv[2]; +if (!code) { + console.error('Usage: eval.js '); + process.exit(1); +} + +async function getTargets() { + return new Promise((resolve, reject) => { + http.get('http://localhost:9222/json', res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +(async () => { + try { + const targets = await getTargets(); + const page = targets.find(t => t.type === 'page'); + if (!page) throw new Error('No active page found'); + + const ws = new WebSocket(page.webSocketDebuggerUrl); + + ws.on('open', () => { + ws.send(JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { + expression: code, + returnByValue: true, + awaitPromise: true + } + })); + }); + + ws.on('message', data => { + const msg = JSON.parse(data); + if (msg.id === 1) { + ws.close(); + if (msg.result.exceptionDetails) { + console.error('Error:', msg.result.exceptionDetails.text); + process.exit(1); + } + console.log(JSON.stringify(msg.result.result.value ?? msg.result.result)); + } + }); + + ws.on('error', e => { + console.error('WebSocket error:', e.message); + process.exit(1); + }); + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +})(); diff --git a/skills/browser/scripts/nav.cjs b/skills/browser/scripts/nav.cjs new file mode 100755 index 0000000..0ce4f1f --- /dev/null +++ b/skills/browser/scripts/nav.cjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Navigate to URL in current or new tab +const http = require('http'); + +const url = process.argv[2]; +const newTab = process.argv.includes('--new'); + +if (!url) { + console.error('Usage: nav.js [--new]'); + process.exit(1); +} + +async function getTargets() { + return new Promise((resolve, reject) => { + http.get('http://localhost:9222/json', res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +async function createTab(url) { + return new Promise((resolve, reject) => { + http.get(`http://localhost:9222/json/new?${encodeURIComponent(url)}`, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +async function navigate(targetId, url) { + const WebSocket = require('ws'); + const targets = await getTargets(); + const target = targets.find(t => t.id === targetId); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(target.webSocketDebuggerUrl); + ws.on('open', () => { + ws.send(JSON.stringify({ id: 1, method: 'Page.navigate', params: { url } })); + }); + ws.on('message', data => { + const msg = JSON.parse(data); + if (msg.id === 1) { + ws.close(); + resolve(msg.result); + } + }); + ws.on('error', reject); + }); +} + +(async () => { + try { + if (newTab) { + const tab = await createTab(url); + console.log(JSON.stringify({ action: 'created', tabId: tab.id, url })); + } else { + const targets = await getTargets(); + const page = targets.find(t => t.type === 'page'); + if (!page) throw new Error('No active page found'); + await navigate(page.id, url); + console.log(JSON.stringify({ action: 'navigated', tabId: page.id, url })); + } + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +})(); diff --git a/skills/browser/scripts/pick.cjs b/skills/browser/scripts/pick.cjs new file mode 100755 index 0000000..fdc762a --- /dev/null +++ b/skills/browser/scripts/pick.cjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Visual element picker - click to select DOM nodes +const http = require('http'); +const WebSocket = require('ws'); + +const hint = process.argv[2] || 'Click an element to select it'; + +async function getTargets() { + return new Promise((resolve, reject) => { + http.get('http://localhost:9222/json', res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +const pickerScript = ` +(function(hint) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999999;cursor:crosshair;'; + + const label = document.createElement('div'); + label.textContent = hint; + label.style.cssText = 'position:fixed;top:10px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:8px 16px;border-radius:4px;z-index:1000000;font:14px sans-serif;'; + + document.body.appendChild(overlay); + document.body.appendChild(label); + + overlay.onclick = e => { + overlay.remove(); + label.remove(); + const el = document.elementFromPoint(e.clientX, e.clientY); + if (!el) return resolve(null); + + const rect = el.getBoundingClientRect(); + resolve({ + tag: el.tagName.toLowerCase(), + id: el.id || null, + classes: [...el.classList], + text: el.textContent?.slice(0, 100)?.trim() || null, + href: el.href || null, + selector: el.id ? '#' + el.id : el.className ? el.tagName.toLowerCase() + '.' + [...el.classList].join('.') : el.tagName.toLowerCase(), + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }); + }; + }); +})`; + +(async () => { + try { + const targets = await getTargets(); + const page = targets.find(t => t.type === 'page'); + if (!page) throw new Error('No active page found'); + + const ws = new WebSocket(page.webSocketDebuggerUrl); + + ws.on('open', () => { + ws.send(JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { + expression: `${pickerScript}(${JSON.stringify(hint)})`, + returnByValue: true, + awaitPromise: true + } + })); + }); + + ws.on('message', data => { + const msg = JSON.parse(data); + if (msg.id === 1) { + ws.close(); + console.log(JSON.stringify(msg.result.result.value, null, 2)); + } + }); + + ws.on('error', e => { + console.error('WebSocket error:', e.message); + process.exit(1); + }); + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +})(); diff --git a/skills/browser/scripts/screenshot.cjs b/skills/browser/scripts/screenshot.cjs new file mode 100755 index 0000000..99a3919 --- /dev/null +++ b/skills/browser/scripts/screenshot.cjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Capture screenshot of the active browser tab +const http = require('http'); +const WebSocket = require('ws'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +async function getTargets() { + return new Promise((resolve, reject) => { + http.get('http://localhost:9222/json', res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(JSON.parse(data))); + }).on('error', reject); + }); +} + +(async () => { + try { + const targets = await getTargets(); + const page = targets.find(t => t.type === 'page'); + if (!page) throw new Error('No active page found'); + + const ws = new WebSocket(page.webSocketDebuggerUrl); + + ws.on('open', () => { + ws.send(JSON.stringify({ + id: 1, + method: 'Page.captureScreenshot', + params: { format: 'png' } + })); + }); + + ws.on('message', data => { + const msg = JSON.parse(data); + if (msg.id === 1) { + ws.close(); + const filename = `screenshot-${Date.now()}.png`; + const filepath = path.join(os.tmpdir(), filename); + fs.writeFileSync(filepath, Buffer.from(msg.result.data, 'base64')); + console.log(JSON.stringify({ path: filepath, filename })); + } + }); + + ws.on('error', e => { + console.error('WebSocket error:', e.message); + process.exit(1); + }); + } catch (e) { + console.error('Error:', e.message); + process.exit(1); + } +})(); diff --git a/skills/browser/scripts/start.cjs b/skills/browser/scripts/start.cjs new file mode 100755 index 0000000..48971ba --- /dev/null +++ b/skills/browser/scripts/start.cjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// Launch Chrome with remote debugging on port 9222 +const { execSync, spawn } = require('child_process'); +const path = require('path'); +const os = require('os'); + +const useProfile = process.argv.includes('--profile'); +const port = 9222; + +// Find Chrome executable +const chromePaths = { + darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + linux: '/usr/bin/google-chrome', + win32: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' +}; +const chromePath = chromePaths[process.platform]; + +// Build args +const args = [ + `--remote-debugging-port=${port}`, + '--no-first-run', + '--no-default-browser-check' +]; + +if (useProfile) { + const profileDir = path.join(os.homedir(), '.chrome-debug-profile'); + args.push(`--user-data-dir=${profileDir}`); +} else { + args.push(`--user-data-dir=${path.join(os.tmpdir(), 'chrome-debug-' + Date.now())}`); +} + +console.log(`Starting Chrome on port ${port}${useProfile ? ' (with profile)' : ''}...`); +const chrome = spawn(chromePath, args, { detached: true, stdio: 'ignore' }); +chrome.unref(); +console.log(`Chrome launched (PID: ${chrome.pid})`);