Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:05:55 +08:00
commit 89c3c1ab83
18 changed files with 1955 additions and 0 deletions

View File

@@ -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"
]
}

3
README.md Normal file
View File

@@ -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.

18
agents/browser-agent.md Normal file
View File

@@ -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.

101
plugin.lock.json Normal file
View File

@@ -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": []
}
}

View File

@@ -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=<number>` (default 9222), `--host=<host>`, `--ws=<ws-endpoint>`, and `--timeout=<ms>`. 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=<path>`, `--user-data-dir=<path>` | `{ ok, port, userDataDir, chromePath, profile }` |
| `scripts/navigate.js` | Open a URL in the active tab or a new one. | `<url>`, `--new`, `--wait=domcontentloaded|networkidle0|load|none` | `{ ok, url, newPage }` |
| `scripts/screenshot.js` | Capture full page or element screenshots. | `--element=<selector>`, `--format=png|jpeg`, `--quality=<1-100>`, `--out=<path>` | `{ ok, path, format, width, height, element }` |
| `scripts/element.js` | Resolve elements by selector/text or interactively pick them. | `<selector>`, `--text=<string>`, `--click`, `--scroll` | `{ ok, selector, tag, id, classes, text, visible, rect }` |
| `scripts/evaluate.js` | Execute JavaScript in the page context. | `<expression>`, `--file=<path>` | `{ ok, result }` (with structured clone) |
| `scripts/cookies.js` | Export, import, or clear cookies via CDP. | `--export[=file]`, `--import=<file>`, `--clear`, `--domain=<filter>` | 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 60s. 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.

View File

@@ -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 <will@ggl.slmail.me>",
"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"
}

View File

@@ -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.

View File

@@ -0,0 +1,72 @@
## Command Reference
All scripts accept the shared flags `--json`, `--quiet`, `--port=<number>`, `--host=<host>`, `--ws=<endpoint>`, and `--timeout=<ms>`. 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=<path>] [--user-data-dir=<path>]
```
- 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 <url> [--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 <expression>
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 8KB) is echoed to STDOUT.
### screenshot.js
```bash
node scripts/screenshot.js [--element=<selector>] [--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 <selector>
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 60s 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 }`.

View File

@@ -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 60seconds; 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/<id>` and omit the local port flag.
- All scripts accept `--ws` to override the connection endpoint per invocation.

View File

@@ -0,0 +1,7 @@
## Security Guidelines
- **Profiles**: Using `--profile` clones your default Chrome profile into `~/.cache/browser-tools/profile-<port>`. 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.

View File

@@ -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"] },
];
}

View File

@@ -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;
}

View File

@@ -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 <file> | --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;
}

View File

@@ -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`;
}

View File

@@ -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;
}

View File

@@ -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 <url> [--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;
}

View File

@@ -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}`);
}

View File

@@ -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 [];
}