Initial commit
This commit is contained in:
41
skills/browser-tools/SKILL.md
Normal file
41
skills/browser-tools/SKILL.md
Normal 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 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.
|
||||
47
skills/browser-tools/package.json
Normal file
47
skills/browser-tools/package.json
Normal 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"
|
||||
}
|
||||
25
skills/browser-tools/references/01-getting-started.md
Normal file
25
skills/browser-tools/references/01-getting-started.md
Normal 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.
|
||||
72
skills/browser-tools/references/02-commands.md
Normal file
72
skills/browser-tools/references/02-commands.md
Normal 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 8 KB) 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 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 }`.
|
||||
29
skills/browser-tools/references/03-troubleshooting.md
Normal file
29
skills/browser-tools/references/03-troubleshooting.md
Normal 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 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/<id>` and omit the local port flag.
|
||||
- All scripts accept `--ws` to override the connection endpoint per invocation.
|
||||
7
skills/browser-tools/references/04-security.md
Normal file
7
skills/browser-tools/references/04-security.md
Normal 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.
|
||||
126
skills/browser-tools/scripts/close.js
Executable file
126
skills/browser-tools/scripts/close.js
Executable 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"] },
|
||||
];
|
||||
}
|
||||
217
skills/browser-tools/scripts/config.js
Executable file
217
skills/browser-tools/scripts/config.js
Executable 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;
|
||||
}
|
||||
229
skills/browser-tools/scripts/cookies.js
Executable file
229
skills/browser-tools/scripts/cookies.js
Executable 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;
|
||||
}
|
||||
375
skills/browser-tools/scripts/element.js
Executable file
375
skills/browser-tools/scripts/element.js
Executable 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`;
|
||||
}
|
||||
114
skills/browser-tools/scripts/evaluate.js
Executable file
114
skills/browser-tools/scripts/evaluate.js
Executable 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;
|
||||
}
|
||||
103
skills/browser-tools/scripts/navigate.js
Executable file
103
skills/browser-tools/scripts/navigate.js
Executable 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;
|
||||
}
|
||||
159
skills/browser-tools/scripts/screenshot.js
Executable file
159
skills/browser-tools/scripts/screenshot.js
Executable 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}`);
|
||||
}
|
||||
275
skills/browser-tools/scripts/start.js
Executable file
275
skills/browser-tools/scripts/start.js
Executable 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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user