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 0000000..0efcd63 Binary files /dev/null and b/skills/browser/browser.zip differ 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})`);