From 89c3c1ab83de2f661b52b0aa0aab22754df71b9a Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 09:05:55 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 14 + README.md | 3 + agents/browser-agent.md | 18 + plugin.lock.json | 101 +++++ skills/browser-tools/SKILL.md | 41 ++ skills/browser-tools/package.json | 47 +++ .../references/01-getting-started.md | 25 ++ .../browser-tools/references/02-commands.md | 72 ++++ .../references/03-troubleshooting.md | 29 ++ .../browser-tools/references/04-security.md | 7 + skills/browser-tools/scripts/close.js | 126 ++++++ skills/browser-tools/scripts/config.js | 217 ++++++++++ skills/browser-tools/scripts/cookies.js | 229 +++++++++++ skills/browser-tools/scripts/element.js | 375 ++++++++++++++++++ skills/browser-tools/scripts/evaluate.js | 114 ++++++ skills/browser-tools/scripts/navigate.js | 103 +++++ skills/browser-tools/scripts/screenshot.js | 159 ++++++++ skills/browser-tools/scripts/start.js | 275 +++++++++++++ 18 files changed, 1955 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 agents/browser-agent.md create mode 100644 plugin.lock.json create mode 100644 skills/browser-tools/SKILL.md create mode 100644 skills/browser-tools/package.json create mode 100644 skills/browser-tools/references/01-getting-started.md create mode 100644 skills/browser-tools/references/02-commands.md create mode 100644 skills/browser-tools/references/03-troubleshooting.md create mode 100644 skills/browser-tools/references/04-security.md create mode 100755 skills/browser-tools/scripts/close.js create mode 100755 skills/browser-tools/scripts/config.js create mode 100755 skills/browser-tools/scripts/cookies.js create mode 100755 skills/browser-tools/scripts/element.js create mode 100755 skills/browser-tools/scripts/evaluate.js create mode 100755 skills/browser-tools/scripts/navigate.js create mode 100755 skills/browser-tools/scripts/screenshot.js create mode 100755 skills/browser-tools/scripts/start.js diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..c7e75c5 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "browser-tools", + "description": "Efficient browser automation tools using Node.js and the Chrome DevTools Protocol for navigation, scripting, and screenshots without MCP overhead.", + "version": "1.0.0", + "author": { + "name": "Will Hampson" + }, + "skills": [ + "./skills" + ], + "agents": [ + "./agents" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..70cd6be --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# browser-tools + +Efficient browser automation tools using Node.js and the Chrome DevTools Protocol for navigation, scripting, and screenshots without MCP overhead. diff --git a/agents/browser-agent.md b/agents/browser-agent.md new file mode 100644 index 0000000..9f3e0c2 --- /dev/null +++ b/agents/browser-agent.md @@ -0,0 +1,18 @@ +--- +name: browser-tools-agent +description: Use this agent proactively when you need to use browser-tools skills +tools: Bash, Glob, Grep, Read, Edit, Write, NotebookEdit, WebFetch, TodoWrite, WebSearch, BashOutput, KillShell, AskUserQuestion, Skill, SlashCommand, +model: inherit +color: green +--- + +# Browser-Tools Agent + +## Routing Guide + +1. **Start Chrome**: If no DevTools session is available, run `skill:browser-tools/scripts/start.js` with `--profile` when the user requests persisted auth. +2. **Navigate & Inspect**: For page interactions use `navigate.js`, `evaluate.js`, and `element.js` (interactive picker enables precise selectors). Prefer `--json` when feeding results into follow-up commands. +3. **Capture & Persist**: Choose `screenshot.js` for visual artifacts and `cookies.js` for session transfer (`--domain` narrows scope). +4. **Shutdown**: When automation is finished or a port conflict arises, call `close.js` and escalate to `--force` only if the DevTools endpoint is unresponsive. + +Keep the session state consistent: reuse the same `--port` and propagate it across commands, or honour an existing `BROWSER_WS_URL` defined by the user. \ No newline at end of file diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..1a4aa06 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,101 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:Whamp/whamp-claude-tools:browser-tools-plugin", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "43ad238d9a702077bfc62a451b1dfa803ddc3f3f", + "treeHash": "6d0c55ffddbc0bf33fe9b910ebb41922ea9278a5cbb05e229e598b867b72cdef", + "generatedAt": "2025-11-28T10:12:57.678370Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "browser-tools", + "description": "Efficient browser automation tools using Node.js and the Chrome DevTools Protocol for navigation, scripting, and screenshots without MCP overhead.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "54f0a1e7e1d6340835e6910733cb27dffbe8bd8131df5d631ef5a8471456ddf6" + }, + { + "path": "agents/browser-agent.md", + "sha256": "0417ff3674df7ea2c4cc7b7df6d53f7c404d042f8d6b880277bdb43258d95e29" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "925b2b764512115e6bbdc7b0827668e5f893f20d339b723fdaf5511f7b5826a1" + }, + { + "path": "skills/browser-tools/package.json", + "sha256": "e24f557da48775575c457663d50aa589b7a110931bc7fb6e2d5875a66e12a3cf" + }, + { + "path": "skills/browser-tools/SKILL.md", + "sha256": "9253fa230a743fc77640ac6904e96fcd0711026915453d6e9b46937b5ca67934" + }, + { + "path": "skills/browser-tools/references/01-getting-started.md", + "sha256": "6c8dca4b6333a5c8ba76d5e18aa7536b8453bea5c6fe8b48e3849110ab47c14a" + }, + { + "path": "skills/browser-tools/references/03-troubleshooting.md", + "sha256": "c02b7ba12ab786d786e84628ef6d4faa63cdf0cdc4d787570226712a7b451c61" + }, + { + "path": "skills/browser-tools/references/04-security.md", + "sha256": "14727009861e15475267b75eab0538666caf503212e44e1bc272be9c634d0881" + }, + { + "path": "skills/browser-tools/references/02-commands.md", + "sha256": "7e2f3d22928c072440846c81f352dc61b9c00e157ca56b261c534a43a373faeb" + }, + { + "path": "skills/browser-tools/scripts/element.js", + "sha256": "676fee2c985487f27178091ce91b33b99f728d5101ca780829ba1e7c4c2f5705" + }, + { + "path": "skills/browser-tools/scripts/start.js", + "sha256": "873c843443fd4b9b90d2040f4c715d60db6baff2cd08fd67b762b10e9e59bb65" + }, + { + "path": "skills/browser-tools/scripts/navigate.js", + "sha256": "3ac9ff16caee8871d6fc4fad537c1e176957d06814d002e155545421ae028794" + }, + { + "path": "skills/browser-tools/scripts/cookies.js", + "sha256": "cfde8c3f9e7fd6525932a13658f24594082836a5589e2d3e4765effabfe5cee9" + }, + { + "path": "skills/browser-tools/scripts/config.js", + "sha256": "6c347ccd0125f3b844738fddd14e9682e9f4b71277b8c547921608bf130852c4" + }, + { + "path": "skills/browser-tools/scripts/close.js", + "sha256": "799d229f7dfc65db520a7e6a1bf4c43af324ebc2f52a098f95fe49cbafd73ab2" + }, + { + "path": "skills/browser-tools/scripts/screenshot.js", + "sha256": "b6008164ed37bbe2d4cdb87725fa2e59b5327b450f0e79e17d80aea0a533c923" + }, + { + "path": "skills/browser-tools/scripts/evaluate.js", + "sha256": "f33afee0c531d98cd7fa6fe62ca38ad04ec41b34bd6e011290468e28f41f80b5" + } + ], + "dirSha256": "6d0c55ffddbc0bf33fe9b910ebb41922ea9278a5cbb05e229e598b867b72cdef" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/browser-tools/SKILL.md b/skills/browser-tools/SKILL.md new file mode 100644 index 0000000..18e7dc9 --- /dev/null +++ b/skills/browser-tools/SKILL.md @@ -0,0 +1,41 @@ +--- +name: browser-tools +description: Lightweight Chrome automation toolkit with shared configuration, JSON-first output, and six focused scripts for starting, navigating, inspecting, capturing, evaluating, and cleaning up browser sessions. +--- + +# Browser Tools Skill + +Use these scripts to control an existing Chrome/Chromium instance via the DevTools protocol. All commands share the flags `--json`, `--quiet`, `--port=` (default 9222), `--host=`, `--ws=`, and `--timeout=`. When `--json` is omitted, machine-readable results are still emitted to STDOUT; human-readable logs go to STDERR. + +| Script | Purpose | Key Flags | JSON Output Snapshot | +| --- | --- | --- | --- | +| `scripts/start.js` | Launch Chrome with remote debugging and optional profile sync. | `--profile[=path]`, `--chrome-path=`, `--user-data-dir=` | `{ ok, port, userDataDir, chromePath, profile }` | +| `scripts/navigate.js` | Open a URL in the active tab or a new one. | ``, `--new`, `--wait=domcontentloaded|networkidle0|load|none` | `{ ok, url, newPage }` | +| `scripts/screenshot.js` | Capture full page or element screenshots. | `--element=`, `--format=png|jpeg`, `--quality=<1-100>`, `--out=` | `{ ok, path, format, width, height, element }` | +| `scripts/element.js` | Resolve elements by selector/text or interactively pick them. | ``, `--text=`, `--click`, `--scroll` | `{ ok, selector, tag, id, classes, text, visible, rect }` | +| `scripts/evaluate.js` | Execute JavaScript in the page context. | ``, `--file=` | `{ ok, result }` (with structured clone) | +| `scripts/cookies.js` | Export, import, or clear cookies via CDP. | `--export[=file]`, `--import=`, `--clear`, `--domain=` | Export payload or `{ ok, imported|cleared }` | +| `scripts/close.js` | Gracefully or forcefully stop Chrome. | `--force` | `{ ok, port, graceful, forced, closedTabs }` | + +## Usage Patterns + +- **Start session**: `node scripts/start.js --profile` → reuse default Chrome profile; or specify `--chrome-path` on Linux/Windows. +- **Pipe commands**: combine navigation, evaluation, and screenshots within a single session: `node scripts/navigate.js https://example.com && node scripts/evaluate.js 'document.title'`. +- **JSON mode for agents**: append `--json` to produce structured payloads for toolchains. + +## Element Picker Notes + +`element.js` supports three modes: +- `element.js '.selector'` – direct CSS lookup. +- `element.js --text "Buy now"` – XPath text match. +- `element.js` – interactive picker; click on the desired element in Chrome within 60 s. The command captures selector metadata and emits it as JSON. + +## Cookie Workflow + +- Export all cookies: `node scripts/cookies.js --export cookies.json`. +- Filter by domain: add `--domain example.com` (applies to export and clear). +- Import from captured payload: `node scripts/cookies.js --import cookies.json --json` (reloads the current page). + +## Shutdown + +- Prefer `node scripts/close.js` for graceful closure; add `--force` when the DevTools endpoint is unresponsive. \ No newline at end of file diff --git a/skills/browser-tools/package.json b/skills/browser-tools/package.json new file mode 100644 index 0000000..c27f6b2 --- /dev/null +++ b/skills/browser-tools/package.json @@ -0,0 +1,47 @@ +{ + "name": "browser-tools-skill", + "version": "1.0.0", + "description": "Efficient browser automation tools using Node.js and Chrome DevTools Protocol", + "type": "module", + "main": "scripts/start.js", + "scripts": { + "start": "node ./scripts/start.js", + "navigate": "node ./scripts/navigate.js", + "eval": "node ./scripts/evaluate.js", + "screenshot": "node ./scripts/screenshot.js", + "element": "node ./scripts/element.js", + "cookies": "node ./scripts/cookies.js", + "close": "node ./scripts/close.js" + }, + "bin": { + "browser-start": "./scripts/start.js", + "browser-navigate": "./scripts/navigate.js", + "browser-eval": "./scripts/evaluate.js", + "browser-screenshot": "./scripts/screenshot.js", + "browser-element": "./scripts/element.js", + "browser-cookies": "./scripts/cookies.js", + "browser-close": "./scripts/close.js" + }, + "keywords": [ + "browser", + "automation", + "chrome", + "devtools", + "puppeteer", + "web-scraping", + "testing" + ], + "author": "Will Hampson ", + "license": "MIT", + "dependencies": { + "puppeteer-core": "^23.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/Whamp/browser-tools-plugin.git" + }, + "homepage": "https://github.com/Whamp/browser-tools-plugin#readme" +} \ No newline at end of file diff --git a/skills/browser-tools/references/01-getting-started.md b/skills/browser-tools/references/01-getting-started.md new file mode 100644 index 0000000..e30b7cb --- /dev/null +++ b/skills/browser-tools/references/01-getting-started.md @@ -0,0 +1,25 @@ +## Getting Started + +The browser-tools skill wraps a set of executable Node.js scripts located in `skills/browser-tools/scripts`. They expect an existing Chrome or Chromium build with remote debugging enabled on port 9222 by default. + +### Install dependencies + +```bash +cd skills/browser-tools +npm install +``` + +### Launch Chrome + +```bash +# Start with a temporary profile +node scripts/start.js + +# Reuse your default profile (copies it into a sandbox) +node scripts/start.js --profile + +# Specify a custom Chrome executable +node scripts/start.js --chrome-path /usr/bin/google-chrome-stable +``` + +After a successful start the script prints JSON describing the session (`port`, `userDataDir`, and chosen executable). All other tools assume the Chrome instance remains available on the same debugging endpoint. diff --git a/skills/browser-tools/references/02-commands.md b/skills/browser-tools/references/02-commands.md new file mode 100644 index 0000000..37e6ad0 --- /dev/null +++ b/skills/browser-tools/references/02-commands.md @@ -0,0 +1,72 @@ +## Command Reference + +All scripts accept the shared flags `--json`, `--quiet`, `--port=`, `--host=`, `--ws=`, and `--timeout=`. Without `--json`, STDOUT still emits machine-readable JSON while human logs go to STDERR. + +### start.js + +```bash +node scripts/start.js [--profile[=path]] [--chrome-path=] [--user-data-dir=] +``` + +- Detects Chrome/Chromium automatically across macOS, Linux, and Windows. +- Copies the default browser profile when `--profile` is set, or from a custom path when supplied. +- Returns `{ ok, port, userDataDir, chromePath, profile }`. + +### navigate.js + +```bash +node scripts/navigate.js [--new] [--wait=domcontentloaded|networkidle0|load|none] +``` + +- Reuses the active tab by default; `--new` opens a new page. +- Emits `{ ok, url, newPage }`. + +### evaluate.js + +```bash +node scripts/evaluate.js +node scripts/evaluate.js --file snippet.js +``` + +- Supports inline, file-based, or piped expressions; structured clone results are returned under `result`. +- When not in `--json` mode the evaluated value (truncated to 8 KB) is echoed to STDOUT. + +### screenshot.js + +```bash +node scripts/screenshot.js [--element=] [--format=png|jpeg] [--quality=80] [--out=path] +``` + +- Saves to a temporary directory when `--out` is omitted and prints the absolute path. +- Element captures rely on the supplied selector; full-page captures default to PNG. + +### element.js + +```bash +node scripts/element.js +node scripts/element.js --text "Buy now" +node scripts/element.js # interactive picker +``` + +- `--click` and `--scroll` act on the resolved element before returning metadata. +- Interactive mode waits up to 60 s for a click inside Chrome and restores page styling afterwards. + +### cookies.js + +```bash +node scripts/cookies.js --export [path] [--domain=example.com] +node scripts/cookies.js --import cookies.json +node scripts/cookies.js --clear [--domain=example.com] +``` + +- Uses Chrome DevTools `Network.*` commands for reliable cookie management. +- Export payloads include metadata (`exportedAt`, `pageUrl`, `cookies[]`). + +### close.js + +```bash +node scripts/close.js [--force] +``` + +- Attempts graceful shutdown first; falls back to process termination on failure. +- Returns `{ ok, port, graceful, forced, closedTabs }`. diff --git a/skills/browser-tools/references/03-troubleshooting.md b/skills/browser-tools/references/03-troubleshooting.md new file mode 100644 index 0000000..1d10c3b --- /dev/null +++ b/skills/browser-tools/references/03-troubleshooting.md @@ -0,0 +1,29 @@ +## Troubleshooting + +### Chrome executable not found + +- Provide the path explicitly: `node scripts/start.js --chrome-path /path/to/chrome`. +- On Linux ensure the binary is accessible (`/usr/bin/google-chrome`, `/usr/bin/chromium`). +- On Windows set `CHROME_PATH="C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"`. + +### Port 9222 already in use + +- Run `node scripts/close.js` to recycle the existing session. +- Override the debugging port: `node scripts/start.js --port 9333` then pass `--port 9333` to subsequent commands. + +### Element picker timeout + +- Bring the Chrome window to the foreground before running `element.js` with no arguments. +- Click within 60 seconds; press `Ctrl+C` to abort the command if you need to restart. +- Use `--selector` or `--text` when pages block pointer events. + +### Cookie import errors + +- Ensure the JSON file contains a `cookies[]` array with `name`, `value`, and `domain` fields. +- Host-only cookies require a `domain` value (e.g., `example.com`); the script converts `sameSite` to the correct case automatically. +- If the page reload fails, refresh manually—cookies are already written to the browser profile. + +### Headless or remote Chrome targets + +- When connecting to a remote browser, set `BROWSER_WS_URL=ws://host:port/devtools/browser/` and omit the local port flag. +- All scripts accept `--ws` to override the connection endpoint per invocation. diff --git a/skills/browser-tools/references/04-security.md b/skills/browser-tools/references/04-security.md new file mode 100644 index 0000000..8b16865 --- /dev/null +++ b/skills/browser-tools/references/04-security.md @@ -0,0 +1,7 @@ +## Security Guidelines + +- **Profiles**: Using `--profile` clones your default Chrome profile into `~/.cache/browser-tools/profile-`. Remove the directory after sensitive sessions or reuse `close.js` which leaves the copy on disk for next runs. +- **Credentials**: Exported cookie JSON files may contain session tokens. Store them outside version control and delete when no longer needed. +- **JavaScript execution**: Only evaluate trusted code. Prefer passing scripts via `--file` to keep complex payloads auditable. +- **Remote endpoints**: When targeting remote Chrome instances with `--ws`, ensure the connection is tunneled (SSH, VPN) because the DevTools protocol provides full browser access. +- **Force shutdown**: `close.js --force` uses process termination (`pkill`/`taskkill`). Verify no unrelated Chrome sessions share the same user data directory before invoking it. diff --git a/skills/browser-tools/scripts/close.js b/skills/browser-tools/scripts/close.js new file mode 100755 index 0000000..3b23a4f --- /dev/null +++ b/skills/browser-tools/scripts/close.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + delay, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["force", "json", "quiet"], + string: ["ws", "host"], + number: ["port", "timeout"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 5000, + }, +}); + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const port = normalizeNumber(args.port, DEFAULT_PORT); +const timeout = normalizeNumber(args.timeout, 5000); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +const result = { + ok: true, + port, + graceful: false, + forced: false, + closedTabs: 0, +}; + +logger.info("🔄 Shutting down browser..."); + +let browser; +if (!args.force) { + try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); + const pages = await browser.pages(); + result.closedTabs = pages.length; + await browser.close(); + result.graceful = true; + logger.info(`✅ Closed browser gracefully (${pages.length} tabs)`); + } catch (error) { + logger.warn(`⚠️ Graceful shutdown failed: ${error.message}`); + } finally { + if (browser) await browser.disconnect(); + } +} + +if (!result.graceful) { + const forced = await forceCloseProcesses({ logger, port }); + result.forced = forced; + if (!forced) { + logger.warn("⚠️ No Chrome processes were terminated"); + } +} + +await delay(300); + +if (jsonOutput) { + printJSON(result); +} else { + process.stdout.write(`${JSON.stringify(result)}\n`); + logger.info("🎉 Browser shutdown complete"); +} + +async function forceCloseProcesses({ logger: log, port: debuggingPort }) { + const commands = buildTerminationCommands(); + let anyKilled = false; + + for (const command of commands) { + const { file, args } = command; + const execution = spawnSync(file, args, { stdio: "ignore" }); + if (execution.status === 0) { + anyKilled = true; + log.info(`🔨 Executed ${file} ${args.join(" ")}`); + } + } + + // Attempt to free the port via lsof if available (Unix only) + if (platform() !== "win32") { + const killByPort = spawnSync("bash", ["-c", `lsof -ti:${debuggingPort} 2>/dev/null | xargs -r kill -9`], { + stdio: "ignore", + }); + if (killByPort.status === 0) { + anyKilled = true; + log.info(`🔨 Cleared processes on port ${debuggingPort}`); + } + } + + return anyKilled; +} + +function buildTerminationCommands() { + if (platform() === "win32") { + return [ + { file: "taskkill", args: ["/F", "/IM", "chrome.exe", "/T"] }, + { file: "taskkill", args: ["/F", "/IM", "msedge.exe", "/T"] }, + ]; + } + + return [ + { file: "pkill", args: ["-f", "Google Chrome"] }, + { file: "pkill", args: ["-f", "chrome"] }, + { file: "pkill", args: ["-f", "chromium"] }, + { file: "pkill", args: ["-f", "msedge"] }, + ]; +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/config.js b/skills/browser-tools/scripts/config.js new file mode 100755 index 0000000..d20730d --- /dev/null +++ b/skills/browser-tools/scripts/config.js @@ -0,0 +1,217 @@ +import { existsSync } from "node:fs"; +import { access, constants } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve as resolvePath, join } from "node:path"; + +export const DEFAULT_PORT = 9222; + +const identity = (value) => value; + +export function parseArgs(argv, options = {}) { + const { + boolean = [], + string = [], + number = [], + alias = {}, + defaults = {}, + } = options; + + const boolSet = new Set(boolean.map(normalizeKey)); + const stringSet = new Set(string.map(normalizeKey)); + const numberSet = new Set(number.map(normalizeKey)); + + const aliases = Object.fromEntries( + Object.entries(alias).map(([key, target]) => [normalizeKey(key), normalizeKey(target)]), + ); + + const result = { _: [] }; + + const assignValue = (key, value = true) => { + const normalized = normalizeKey(key); + const target = aliases[normalized] ?? normalized; + result[target] = value; + }; + + for (let index = 0; index < argv.length; index++) { + const token = argv[index]; + + if (token === "--") { + result._.push(...argv.slice(index + 1)); + break; + } + + if (!token.startsWith("-")) { + result._.push(token); + continue; + } + + if (token.startsWith("--no-")) { + const key = token.slice(5); + assignValue(key, false); + continue; + } + + const [rawKey, inlineValue] = token.split("=", 2); + const key = rawKey.replace(/^--?/, ""); + + if (boolSet.has(key)) { + assignValue(key, inlineValue === undefined ? true : coerceBoolean(inlineValue)); + continue; + } + + const expectsString = stringSet.has(key); + const expectsNumber = numberSet.has(key); + + if (inlineValue !== undefined) { + assignValue(key, expectsNumber ? Number(inlineValue) : inlineValue); + continue; + } + + const nextToken = argv[index + 1]; + const hasNext = nextToken !== undefined && !nextToken.startsWith("--"); + + if ((expectsString || expectsNumber) && hasNext) { + index += 1; + assignValue(key, expectsNumber ? Number(nextToken) : nextToken); + } else if (expectsString || expectsNumber) { + assignValue(key, expectsNumber ? NaN : ""); + } else { + assignValue(key, true); + } + } + + for (const [key, value] of Object.entries(defaults)) { + if (!(key in result)) { + result[key] = typeof value === "function" ? value() : value; + } + } + + return result; +} + +export function createLogger({ quiet = false, json = false } = {}) { + const format = (args) => + args + .map((part) => { + if (part instanceof Error) { + return part.stack ?? part.message; + } + return typeof part === "object" ? JSON.stringify(part) : String(part); + }) + .join(" "); + + const write = (stream, args) => { + stream.write(format(args)); + stream.write("\n"); + }; + + return { + info: (...args) => { + if (!quiet && !json) write(process.stderr, args); + }, + warn: (...args) => { + if (!quiet && !json) write(process.stderr, args); + }, + error: (...args) => { + write(process.stderr, args); + }, + }; +} + +export function printJSON(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +export function fail(message, { code = 1, json = false } = {}) { + if (json) { + printJSON({ ok: false, error: message }); + } else { + process.stderr.write(`${message}\n`); + } + process.exit(code); +} + +export function resolveBrowserConnection(flags = {}) { + if (flags.ws || process.env.BROWSER_WS_URL) { + const endpoint = flags.ws ?? process.env.BROWSER_WS_URL; + return { + browserWSEndpoint: endpoint, + defaultViewport: null, + }; + } + + const host = flags.host ?? process.env.BROWSER_HOST ?? "localhost"; + const port = normalizeNumber(flags.port ?? process.env.BROWSER_PORT ?? DEFAULT_PORT, DEFAULT_PORT); + + return { + browserURL: `http://${host}:${port}`, + defaultViewport: null, + }; +} + +export async function waitFor(fn, { timeout = 10000, interval = 250 } = {}) { + const start = Date.now(); + let lastError; + while (Date.now() - start < timeout) { + try { + const result = await fn(); + if (result) return result; + } catch (error) { + lastError = error; + } + await delay(interval); + } + if (lastError) throw lastError; + return null; +} + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function getActivePage(browser, { index = -1 } = {}) { + const pages = await browser.pages(); + if (pages.length === 0) return null; + return index === -1 ? pages.at(-1) : pages[index] ?? null; +} + +export function expandPath(value) { + if (!value) return value; + if (value.startsWith("~")) { + return resolvePath(join(homedir(), value.slice(1))); + } + return resolvePath(value); +} + +export async function pathExists(path) { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +export function normalizeNumber(value, fallback) { + const number = typeof value === "string" ? Number(value) : value; + return Number.isFinite(number) ? number : fallback; +} + +export function findExistingPath(paths) { + for (const candidate of paths) { + if (!candidate) continue; + if (existsSync(candidate)) return candidate; + } + return null; +} + +function normalizeKey(key) { + return key.replace(/^--?/, ""); +} + +function coerceBoolean(value) { + if (typeof value === "boolean") return value; + const normalized = String(value).toLowerCase(); + if (["false", "0", "no", "off"].includes(normalized)) return false; + return true; +} diff --git a/skills/browser-tools/scripts/cookies.js b/skills/browser-tools/scripts/cookies.js new file mode 100755 index 0000000..7d482e3 --- /dev/null +++ b/skills/browser-tools/scripts/cookies.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + fail, + getActivePage, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["clear", "json", "quiet"], + string: ["export", "import", "domain", "ws", "host"], + number: ["port", "timeout"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 15000, + }, +}); + +const mode = resolveMode(args); +if (!mode) { + fail("Specify one command: --export [file] | --import | --clear", { json: Boolean(args.json) }); +} + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const timeout = normalizeNumber(args.timeout, 15000); +const port = normalizeNumber(args.port, DEFAULT_PORT); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +let browser; +try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); +} catch (error) { + fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput }); +} + +try { + const page = await getActivePage(browser, { index: 0 }); + if (!page) { + fail("No active page found. Start a session first.", { json: jsonOutput }); + } + + const client = await page.createCDPSession(); + await client.send("Network.enable"); + + if (mode === "export") { + await handleExport({ page, client, args, logger, jsonOutput }); + } else if (mode === "import") { + await handleImport({ page, client, args, logger, jsonOutput, timeout }); + } else { + await handleClear({ client, args, logger, jsonOutput }); + } +} catch (error) { + fail(`Cookie operation failed: ${error.message}`, { json: jsonOutput }); +} finally { + if (browser) await browser.disconnect(); +} + +function resolveMode(parsed) { + if (typeof parsed.export === "string") return "export"; + if (typeof parsed.import === "string") return "import"; + if (parsed.clear) return "clear"; + return null; +} + +async function handleExport({ page, client, args: parsed, logger: log, jsonOutput }) { + const domainFilter = parsed.domain ?? null; + const outputPath = parsed.export?.trim() ? parsed.export : null; + + const { cookies } = await client.send("Network.getAllCookies"); + + const filtered = (domainFilter + ? cookies.filter((cookie) => cookie.domain.includes(domainFilter)) + : cookies + ).sort((a, b) => { + if (a.domain !== b.domain) return a.domain.localeCompare(b.domain); + return a.name.localeCompare(b.name); + }); + + const payload = { + ok: true, + exportedAt: new Date().toISOString(), + pageUrl: page.url(), + domain: domainFilter, + total: filtered.length, + cookies: filtered.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + expires: cookie.expires, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + priority: cookie.priority, + })), + }; + + if (outputPath) { + await writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + log.info(`🍪 Exported ${filtered.length} cookies to ${outputPath}`); + if (jsonOutput) { + printJSON({ ok: true, path: outputPath, total: filtered.length }); + } + } else if (jsonOutput) { + printJSON(payload); + } else { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } +} + +async function handleImport({ page, client, args: parsed, logger: log, jsonOutput, timeout }) { + const sourcePath = parsed.import?.trim(); + if (!sourcePath) { + fail("--import requires a file path", { json: jsonOutput }); + } + + const content = await readFile(sourcePath, "utf8"); + let payload; + try { + payload = JSON.parse(content); + } catch (error) { + fail(`Invalid JSON in ${sourcePath}: ${error.message}`, { json: jsonOutput }); + } + + if (!payload.cookies || !Array.isArray(payload.cookies)) { + fail("Cookie file missing 'cookies' array", { json: jsonOutput }); + } + + const cookiesToSet = payload.cookies.map((cookie) => normalizeCookie(cookie)); + + await client.send("Network.setCookies", { cookies: cookiesToSet }); + + log.info(`✅ Imported ${cookiesToSet.length} cookies`); + + try { + await page.reload({ waitUntil: "networkidle0", timeout }); + log.info("🔄 Page reloaded to apply cookies"); + } catch (error) { + log.warn(`⚠️ Page reload failed: ${error.message}`); + } + + const result = { ok: true, imported: cookiesToSet.length }; + if (jsonOutput) { + printJSON(result); + } else { + process.stdout.write(`${JSON.stringify(result)}\n`); + } +} + +async function handleClear({ client, args: parsed, logger: log, jsonOutput }) { + const domainFilter = parsed.domain ?? null; + + if (!domainFilter) { + await client.send("Network.clearBrowserCookies"); + const result = { ok: true, cleared: "all" }; + if (jsonOutput) { + printJSON(result); + } else { + process.stdout.write(`${JSON.stringify(result)}\n`); + } + log.info("🧹 Cleared all cookies"); + return; + } + + const { cookies } = await client.send("Network.getAllCookies"); + const filtered = cookies.filter((cookie) => cookie.domain.includes(domainFilter)); + + for (const cookie of filtered) { + await client.send("Network.deleteCookies", { + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + }); + } + + const result = { ok: true, cleared: filtered.length, domain: domainFilter }; + if (jsonOutput) { + printJSON(result); + } else { + process.stdout.write(`${JSON.stringify(result)}\n`); + } + log.info(`🧹 Cleared ${filtered.length} cookies for ${domainFilter}`); +} + +function normalizeCookie(cookie) { + const sameSite = normalizeSameSite(cookie.sameSite); + const param = { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path ?? "/", + }; + + if (cookie.expires && Number.isFinite(cookie.expires)) { + param.expires = cookie.expires; + } + if (cookie.httpOnly !== undefined) param.httpOnly = Boolean(cookie.httpOnly); + if (cookie.secure !== undefined) param.secure = Boolean(cookie.secure); + if (sameSite) param.sameSite = sameSite; + + return param; +} + +function normalizeSameSite(value) { + if (!value) return undefined; + const normalized = String(value).toLowerCase(); + if (normalized === "lax") return "Lax"; + if (normalized === "strict") return "Strict"; + if (normalized === "none") return "None"; + return undefined; +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/element.js b/skills/browser-tools/scripts/element.js new file mode 100755 index 0000000..40fc56d --- /dev/null +++ b/skills/browser-tools/scripts/element.js @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + fail, + getActivePage, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["click", "scroll", "json", "quiet"], + string: ["text", "ws", "host"], + number: ["port", "timeout"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 15000, + }, +}); + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const selector = args._[0] ?? null; +const textSearch = args.text ?? null; +const timeout = normalizeNumber(args.timeout, 15000); +const port = normalizeNumber(args.port, DEFAULT_PORT); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +let browser; +try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); +} catch (error) { + fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput }); +} + +try { + const page = await getActivePage(browser, { index: -1 }); + if (!page) { + fail("No active page found. Navigate first.", { json: jsonOutput }); + } + + const elementResult = await resolveElement(page, { selector, textSearch, timeout }); + if (!elementResult || !elementResult.handle) { + const message = selector + ? `Selector not found: ${selector}` + : textSearch + ? `No element found containing text: ${textSearch}` + : "No element selected."; + fail(message, { json: jsonOutput }); + } + + const { handle, info } = elementResult; + + if (args.scroll) { + await handle.evaluate((el) => { + el.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" }); + }); + logger.info("↕️ Element scrolled into view"); + } + + if (args.click) { + try { + await handle.click({ delay: 20 }); + logger.info("🖱️ Element clicked"); + } catch (error) { + fail(`Click failed: ${error.message}`, { json: jsonOutput }); + } + } + + const output = { + ok: true, + selector: info.selector, + tag: info.tag, + id: info.id, + classes: info.classes, + text: info.text, + attributes: info.attributes, + rect: info.rect, + visible: info.visible, + children: info.children, + }; + + if (jsonOutput) { + printJSON(output); + } else { + logger.info(`✅ Element ${info.selector}`); + process.stdout.write(formatHumanOutput(output)); + } + + await handle.dispose(); +} catch (error) { + fail(`Element lookup failed: ${error.message}`, { json: jsonOutput }); +} finally { + if (browser) await browser.disconnect(); +} + +async function resolveElement(page, { selector: rawSelector, textSearch: rawText, timeout: timeoutMs }) { + if (rawSelector) { + const handle = await page.$(rawSelector); + if (!handle) return null; + const info = await collectElementInfo(page, handle, rawSelector); + return { handle, info }; + } + + if (rawText) { + const handle = await findByText(page, rawText); + if (!handle) return null; + const info = await collectElementInfo(page, handle); + return { handle, info }; + } + + return await pickElement(page, timeoutMs); +} + +async function collectElementInfo(page, handle, selectorOverride) { + return await page.evaluate((el, selectorHint) => { + const escapeIdent = (value) => { + if (window.CSS && typeof window.CSS.escape === "function") { + return window.CSS.escape(value); + } + return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`); + }; + + const toSelector = (element) => { + const parts = []; + let current = element; + while (current && current.nodeType === 1) { + let part = current.nodeName.toLowerCase(); + if (current.id) { + part = `#${escapeIdent(current.id)}`; + parts.unshift(part); + break; + } + + if (current.classList.length > 0) { + part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`; + } + + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + part += `:nth-of-type(${index})`; + } + } + + parts.unshift(part); + current = parent; + } + return parts.join(' > '); + }; + + const rect = el.getBoundingClientRect(); + const attributes = Object.fromEntries( + el.getAttributeNames().map((name) => [name, el.getAttribute(name)]), + ); + + const textContent = el.innerText ?? el.textContent ?? ""; + + return { + selector: selectorHint ?? toSelector(el), + tag: el.tagName.toLowerCase(), + id: el.id || null, + classes: el.classList.length ? Array.from(el.classList) : [], + text: textContent.trim().slice(0, 160), + attributes, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + visible: Boolean(el.offsetParent), + children: el.children.length, + }; + }, handle, selectorOverride ?? null); +} + +async function findByText(page, text) { + const escaped = text.replace(/"/g, '\\"'); + const handles = await page.$x(`//*[contains(normalize-space(text()), "${escaped}")]`); + if (!handles || handles.length === 0) return null; + const [first, ...rest] = handles; + await Promise.all(rest.map((handle) => handle.dispose())); + return first; +} + +async function pickElement(page, timeoutMs) { + const result = await page.evaluate(async (timeout) => { + const originalCursor = document.body?.style?.cursor ?? ""; + const state = { + highlight: null, + previousOutline: null, + resolved: false, + }; + let timerId = null; + + const escapeIdent = (value) => { + if (window.CSS && typeof window.CSS.escape === "function") { + return window.CSS.escape(value); + } + return value.replace(/[^a-zA-Z0-9_\-]/g, (char) => `\\${char}`); + }; + + const cleanup = () => { + if (state.highlight) { + state.highlight.style.outline = state.previousOutline ?? ""; + } + document.body.style.cursor = originalCursor; + document.removeEventListener("mouseover", onHover, true); + document.removeEventListener("click", onClick, true); + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + }; + + const highlight = (element) => { + if (state.highlight === element) return; + if (state.highlight) { + state.highlight.style.outline = state.previousOutline ?? ""; + } + state.highlight = element; + state.previousOutline = element.style.outline; + element.style.outline = "3px solid #ff4444"; + }; + + const buildInfo = (element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + text: (element.innerText ?? element.textContent ?? "").trim().slice(0, 160), + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + }; + }; + + const toSelector = (element) => { + const parts = []; + let current = element; + while (current && current.nodeType === 1) { + let part = current.nodeName.toLowerCase(); + if (current.id) { + part = `#${escapeIdent(current.id)}`; + parts.unshift(part); + break; + } + if (current.classList.length > 0) { + part += `.${Array.from(current.classList, (cls) => escapeIdent(cls)).join('.')}`; + } + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((child) => child.nodeName === current.nodeName); + if (siblings.length > 1) { + const index = siblings.indexOf(current) + 1; + part += `:nth-of-type(${index})`; + } + } + parts.unshift(part); + current = parent; + } + return parts.join(' > '); + }; + + const resolveSelection = (element) => { + if (!element) return null; + const info = buildInfo(element); + const selector = toSelector(element); + window.__BT_PICKED_ELEMENT = element; + return { ...info, selector }; + }; + + const onHover = (event) => { + if (state.resolved) return; + const target = event.target; + if (!(target instanceof HTMLElement)) return; + highlight(target); + }; + + const onClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + if (state.resolved) return; + state.resolved = true; + cleanup(); + resolve(resolveSelection(event.target)); + }; + + document.body.style.cursor = "crosshair"; + document.addEventListener("mouseover", onHover, true); + document.addEventListener("click", onClick, true); + + return await new Promise((resolve) => { + timerId = timeout + ? setTimeout(() => { + if (state.resolved) return; + state.resolved = true; + cleanup(); + resolve(null); + }, timeout) + : null; + + window.addEventListener( + "blur", + () => { + if (state.resolved) return; + state.resolved = true; + cleanup(); + resolve(null); + }, + { once: true }, + ); + + window.__BT_PICKER_CANCEL = () => { + if (state.resolved) return; + state.resolved = true; + cleanup(); + resolve(null); + }; + }); + }, timeoutMs || 60000); + + if (!result) return null; + + const handle = await page.evaluateHandle(() => { + const element = window.__BT_PICKED_ELEMENT ?? null; + delete window.__BT_PICKED_ELEMENT; + delete window.__BT_PICKER_CANCEL; + return element; + }); + + const element = handle.asElement(); + if (!element) { + await handle.dispose(); + return null; + } + + const info = await collectElementInfo(page, element, result.selector); + return { handle: element, info }; +} + +function formatHumanOutput(info) { + const lines = []; + lines.push(`selector: ${info.selector}`); + if (info.tag) lines.push(`tag: ${info.tag}`); + if (info.id) lines.push(`id: ${info.id}`); + if (info.classes?.length) lines.push(`classes: ${info.classes.join(" ")}`); + if (info.text) lines.push(`text: ${info.text}`); + lines.push(`visible: ${info.visible}`); + lines.push(`children: ${info.children}`); + if (info.rect) { + lines.push(`position: (${info.rect.x}, ${info.rect.y})`); + lines.push(`size: ${info.rect.width}x${info.rect.height}`); + } + return `${lines.join("\n")}\n`; +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/evaluate.js b/skills/browser-tools/scripts/evaluate.js new file mode 100755 index 0000000..82e75ea --- /dev/null +++ b/skills/browser-tools/scripts/evaluate.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + fail, + getActivePage, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["json", "quiet"], + string: ["file", "ws", "host"], + number: ["port", "timeout", "truncate"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 30000, + truncate: 8000, + }, +}); + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const expression = await resolveExpression(args); + +if (!expression) { + fail("No JavaScript provided. Pass an expression or use --file/STDIN.", { json: jsonOutput }); +} + +const timeout = normalizeNumber(args.timeout, 30000); +const port = normalizeNumber(args.port, DEFAULT_PORT); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +let browser; +try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); +} catch (error) { + fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput }); +} + +try { + const page = await getActivePage(browser, { index: -1 }); + if (!page) { + fail("No active page found. Navigate to a page first.", { json: jsonOutput }); + } + + const result = await page.evaluate(async (code) => { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const fn = new AsyncFunction(`return (${code})`); + return await fn(); + }, expression); + + const truncated = truncateResult(result, normalizeNumber(args.truncate, 8000)); + + if (jsonOutput) { + printJSON({ ok: true, result }); + } else { + process.stdout.write(`${truncated}\n`); + logger.info(truncated); + } +} catch (error) { + fail(`Evaluation failed: ${error.message}`, { json: jsonOutput }); +} finally { + if (browser) await browser.disconnect(); +} + +async function resolveExpression(parsed) { + if (parsed.file) { + const content = await readFile(parsed.file, "utf8"); + return content.trim(); + } + + if (parsed._.length > 0) { + return parsed._.join(" "); + } + + if (!process.stdin.isTTY) { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8").trim(); + } + + return null; +} + +function truncateResult(value, maxLength) { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + + if (typeof value === "string") { + return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; + } + + const serialized = JSON.stringify(value, null, 2); + if (!serialized) return String(value); + return serialized.length > maxLength ? `${serialized.slice(0, maxLength)}…` : serialized; +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/navigate.js b/skills/browser-tools/scripts/navigate.js new file mode 100755 index 0000000..f47556b --- /dev/null +++ b/skills/browser-tools/scripts/navigate.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + fail, + getActivePage, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["new", "json", "quiet"], + string: ["wait", "ws", "host"], + number: ["port", "timeout"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 30000, + wait: "domcontentloaded", + }, +}); + +const url = args._[0]; + +if (!url) { + fail("Usage: navigate.js [--new] [--wait=domcontentloaded|networkidle0|load|none]", { + json: Boolean(args.json), + }); +} + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const waitStrategy = normalizeWait(args.wait); +if (!waitStrategy) { + fail("Invalid --wait value. Use domcontentloaded, networkidle0, load, or none.", { json: jsonOutput }); +} + +const timeout = normalizeNumber(args.timeout, 30000); +const port = normalizeNumber(args.port, DEFAULT_PORT); + +logger.info(`🌐 Navigating to ${url}${args.new ? " (new tab)" : ""}...`); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +let browser; +try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); +} catch (error) { + fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput }); +} + +try { + const page = args.new ? await browser.newPage() : await getExistingPage(browser); + if (!page) { + fail("No active page found. Start Chrome with start.js or use --new to open a tab.", { json: jsonOutput }); + } + + const gotoOptions = { timeout }; + if (waitStrategy !== "none") { + gotoOptions.waitUntil = waitStrategy; + } + + await page.goto(url, gotoOptions); + + const currentURL = page.url(); + + if (jsonOutput) { + printJSON({ ok: true, url: currentURL, newPage: Boolean(args.new) }); + } else { + process.stdout.write(`${JSON.stringify({ ok: true, url: currentURL, newPage: Boolean(args.new) })}\n`); + logger.info(`✅ Navigated to ${currentURL}`); + } +} catch (error) { + fail(`Navigation failed: ${error.message}`, { json: jsonOutput }); +} finally { + if (browser) await browser.disconnect(); +} + +function normalizeWait(value) { + if (!value) return "domcontentloaded"; + const normalized = String(value).toLowerCase(); + if (["domcontentloaded", "networkidle0", "load", "none"].includes(normalized)) { + return normalized; + } + return null; +} + +async function getExistingPage(browserInstance) { + const page = await getActivePage(browserInstance, { index: -1 }); + return page; +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/screenshot.js b/skills/browser-tools/scripts/screenshot.js new file mode 100755 index 0000000..8ecf9a9 --- /dev/null +++ b/skills/browser-tools/scripts/screenshot.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + fail, + getActivePage, + parseArgs, + printJSON, + resolveBrowserConnection, + normalizeNumber, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["json", "quiet"], + string: ["element", "format", "ws", "host", "out"], + number: ["port", "timeout", "quality"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 30000, + format: "png", + }, +}); + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const timeout = normalizeNumber(args.timeout, 30000); +const port = normalizeNumber(args.port, DEFAULT_PORT); + +const format = normalizeFormat(args.format); +if (!format) { + fail("Invalid --format. Use png or jpeg.", { json: jsonOutput }); +} + +const quality = determineQuality(args.quality, format); + +const connectionOptions = resolveBrowserConnection({ port, host: args.host, ws: args.ws }); + +let browser; +try { + browser = await puppeteer.connect({ + ...connectionOptions, + timeout, + }); +} catch (error) { + fail(`Failed to connect to browser: ${error.message}`, { json: jsonOutput }); +} + +const outputFile = args.out ? args.out : await allocateTempFile(format); + +try { + const page = await getActivePage(browser, { index: -1 }); + if (!page) { + fail("No active page found. Navigate first.", { json: jsonOutput }); + } + + let dimensions; + let buffer; + + if (args.element) { + const handle = await page.$(args.element); + if (!handle) { + fail(`Element not found: ${args.element}`, { json: jsonOutput }); + } + const box = await handle.boundingBox(); + if (!box) { + fail(`Element not visible: ${args.element}`, { json: jsonOutput }); + } + buffer = await handle.screenshot({ + path: outputFile, + type: format, + quality, + }); + dimensions = { + width: Math.round(box.width), + height: Math.round(box.height), + }; + } else { + const metrics = await page.evaluate(() => { + const width = Math.max( + document.documentElement.scrollWidth, + document.body?.scrollWidth ?? 0, + window.innerWidth, + ); + const height = Math.max( + document.documentElement.scrollHeight, + document.body?.scrollHeight ?? 0, + window.innerHeight, + ); + return { + width: Math.round(width), + height: Math.round(height), + }; + }); + + buffer = await page.screenshot({ + path: outputFile, + type: format, + quality, + fullPage: true, + }); + + dimensions = metrics; + } + + if (!buffer) { + fail("Screenshot failed.", { json: jsonOutput }); + } + + const result = { + ok: true, + path: outputFile, + format, + width: dimensions?.width ?? null, + height: dimensions?.height ?? null, + element: Boolean(args.element), + }; + + if (jsonOutput) { + printJSON(result); + } else { + logger.info(`📸 Screenshot saved (${result.width ?? "?"}×${result.height ?? "?"})`); + process.stdout.write(`${outputFile}\n`); + } +} catch (error) { + fail(`Screenshot failed: ${error.message}`, { json: jsonOutput }); +} finally { + if (browser) await browser.disconnect(); +} + +function normalizeFormat(value) { + if (!value) return "png"; + const normalized = String(value).toLowerCase(); + if (["png", "jpeg"].includes(normalized)) return normalized; + return null; +} + +function determineQuality(value, currentFormat) { + if (currentFormat !== "jpeg") return undefined; + if (value === undefined) return 80; + const numeric = normalizeNumber(value, 80); + return Math.min(100, Math.max(1, numeric)); +} + +async function allocateTempFile(currentFormat) { + const directory = await mkdtemp(join(tmpdir(), "browser-tools-")); + return join(directory, `screenshot-${randomUUID()}.${currentFormat}`); +} \ No newline at end of file diff --git a/skills/browser-tools/scripts/start.js b/skills/browser-tools/scripts/start.js new file mode 100755 index 0000000..fbbb063 --- /dev/null +++ b/skills/browser-tools/scripts/start.js @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, rm, cp } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import net from "node:net"; +import puppeteer from "puppeteer-core"; + +import { + DEFAULT_PORT, + createLogger, + delay, + expandPath, + fail, + findExistingPath, + parseArgs, + printJSON, + resolveBrowserConnection, + waitFor, + normalizeNumber, + pathExists, +} from "./config.js"; + +const args = parseArgs(process.argv.slice(2), { + boolean: ["json", "quiet"], + string: ["profile", "profile-path", "chrome-path", "chromePath", "host", "user-data-dir", "ws"], + number: ["port", "timeout"], + alias: { + j: "json", + q: "quiet", + }, + defaults: { + port: DEFAULT_PORT, + timeout: 15000, + }, +}); + +const jsonOutput = Boolean(args.json); +const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput }); + +const port = normalizeNumber(args.port, DEFAULT_PORT); +const timeout = normalizeNumber(args.timeout, 15000); +const host = args.host ?? "127.0.0.1"; + +const userDataDir = expandPath( + args["user-data-dir"] ?? join(homedir(), ".cache", "browser-tools", `profile-${port}`), +); + +await ensurePortAvailable({ port, host }); + +const chromeExecutable = await resolveChromeExecutable(args); +if (!chromeExecutable) { + fail( + "Unable to find Chrome/Chromium executable. Provide --chrome-path or set CHROME_PATH/PUPPETEER_EXECUTABLE_PATH.", + { json: jsonOutput }, + ); +} + +let profileSource = await resolveProfileSource(args); +let copiedProfile = false; + +try { + await rm(userDataDir, { recursive: true, force: true }); + + if (profileSource) { + logger.info(`⏳ Copying profile from ${profileSource}...`); + await cp(profileSource, userDataDir, { recursive: true }); + copiedProfile = true; + } else { + await mkdir(userDataDir, { recursive: true }); + } +} catch (error) { + logger.error(`Failed to prepare user data dir: ${error.message}`); + fail("Failed to prepare profile directory", { json: jsonOutput }); +} + +const chromeArgs = buildChromeArguments({ port, userDataDir }); + +logger.info(`🚀 Launching Chrome at ${chromeExecutable} on port ${port}...`); + +const chromeProcess = spawn(chromeExecutable, chromeArgs, { + detached: true, + stdio: "ignore", +}); + +chromeProcess.unref(); + +logger.info("⏳ Waiting for DevTools endpoint..."); + +const connectionOptions = resolveBrowserConnection({ port, host, ws: args.ws }); + +const connected = await waitFor(async () => { + try { + const browser = await puppeteer.connect({ + ...connectionOptions, + timeout: 2000, + }); + await browser.disconnect(); + return true; + } catch { + return false; + } +}, { timeout, interval: 500 }); + +if (!connected) { + fail( + `Failed to connect to Chrome on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`, + { json: jsonOutput }, + ); +} + +logger.info(`✅ Chrome listening on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`); + +const result = { + ok: true, + port, + userDataDir, + chromePath: chromeExecutable, + profile: copiedProfile ? profileSource : null, +}; + +if (jsonOutput) { + printJSON(result); +} else { + process.stdout.write(`${JSON.stringify(result)}\n`); + logger.info( + `✓ Chrome started on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`, + ); +} + +async function ensurePortAvailable({ port: portToCheck, host: hostToCheck }) { + await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (error) => { + if (error.code === "EADDRINUSE") { + reject( + new Error( + `Port ${portToCheck} is already in use. If Chrome is already running, reconnect with other tools or close it first.`, + ), + ); + } else { + reject(error); + } + }); + server.once("listening", () => { + server.close(() => resolve()); + }); + server.listen(portToCheck, hostToCheck); + }).catch((error) => { + fail(error.message, { json: jsonOutput }); + }); + + // small delay to allow OS to release the port after closing the test server + await delay(50); +} + +function buildChromeArguments({ port: portValue, userDataDir: dir }) { + return [ + `--remote-debugging-port=${portValue}`, + `--user-data-dir=${dir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-sync", + "--metrics-recording-only", + "--enable-automation", + ]; +} + +async function resolveChromeExecutable(parsed) { + const providedPath = parsed["chrome-path"] ?? parsed.chromePath ?? process.env.CHROME_PATH; + const puppeteerPath = process.env.PUPPETEER_EXECUTABLE_PATH; + + const platformCandidates = getDefaultChromeCandidates(); + + return findExistingPath([ + providedPath && expandPath(providedPath), + puppeteerPath && expandPath(puppeteerPath), + ...platformCandidates, + ]); +} + +async function resolveProfileSource(parsed) { + const raw = parsed.profile ?? parsed["profile-path"]; + + if (raw === undefined || raw === false) return null; + + const normalized = typeof raw === "string" ? raw.trim() : ""; + + if (normalized && normalized.toLowerCase() !== "default") { + const expanded = expandPath(normalized); + if (await pathExists(expanded)) return expanded; + logger.warn(`⚠️ Profile path not found: ${expanded}`); + return null; + } + + const defaultPath = await resolveDefaultProfileRoot(); + if (!defaultPath) { + logger.warn("⚠️ No default Chrome profile detected; starting with a fresh profile."); + return null; + } + return defaultPath; +} + +async function resolveDefaultProfileRoot() { + const candidates = getDefaultProfileCandidates(); + for (const candidate of candidates) { + if (!candidate) continue; + const expanded = expandPath(candidate); + if (await pathExists(expanded)) { + return expanded; + } + } + return null; +} + +function getDefaultChromeCandidates() { + const platform = process.platform; + const home = homedir(); + + if (platform === "darwin") { + return [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; + } + + if (platform === "linux") { + return [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + join(home, "snap", "chromium", "current", "usr", "lib", "chromium-browser", "chromium-browser"), + ]; + } + + if (platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + const programFiles = process.env["PROGRAMFILES(X86)"] ?? process.env.PROGRAMFILES; + return [ + localAppData && join(localAppData, "Google", "Chrome", "Application", "chrome.exe"), + programFiles && join(programFiles, "Google", "Chrome", "Application", "chrome.exe"), + ]; + } + + return []; +} + +function getDefaultProfileCandidates() { + const platform = process.platform; + if (platform === "darwin") { + return [ + "~/Library/Application Support/Google/Chrome", + "~/Library/Application Support/Chromium", + ]; + } + + if (platform === "linux") { + return [ + "~/.config/google-chrome", + "~/.config/chromium", + ]; + } + + if (platform === "win32") { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) return []; + return [join(localAppData, "Google", "Chrome", "User Data")]; + } + + return []; +} \ No newline at end of file