chrome-bridge-mcp 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/extension/console-capture.js +46 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +44 -0
- package/extension/page-instrumentation.js +115 -0
- package/extension/popup.css +128 -0
- package/extension/popup.html +64 -0
- package/extension/popup.js +142 -0
- package/extension/service-worker.js +3711 -0
- package/extension/stack-detect.js +54 -0
- package/extension/telemetry.js +29 -0
- package/install.sh +70 -0
- package/package.json +57 -0
- package/server/assertions.js +81 -0
- package/server/cli.js +380 -0
- package/server/formatters.js +35 -0
- package/server/har.js +22 -0
- package/server/index.js +107 -0
- package/server/launcher.js +97 -0
- package/server/link-checker.js +57 -0
- package/server/protocol.js +135 -0
- package/server/security-headers.js +68 -0
- package/server/stub-server.js +81 -0
- package/server/tools.js +1728 -0
- package/server/ws-manager.js +655 -0
- package/tools/measure-schema.mjs +108 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Euristiche leggere di stack detection, eseguite in MAIN world sulla tab
|
|
3
|
+
* attiva quando l'utente apre il popup. Nessuna libreria esterna: solo
|
|
4
|
+
* global note e indizi DOM. Best-effort dichiarato: un framework ben
|
|
5
|
+
* nascosto (build minificata senza global) non viene rilevato.
|
|
6
|
+
*
|
|
7
|
+
* Nel browser il file viene iniettato via chrome.scripting.executeScript
|
|
8
|
+
* e deposita il risultato in window.__chromeBridge_stackDetect; nei unit
|
|
9
|
+
* test Node si importa per side effect e si usa globalThis.__cbStackDetect.
|
|
10
|
+
*/
|
|
11
|
+
(() => {
|
|
12
|
+
function detectStack(win, doc) {
|
|
13
|
+
const found = [];
|
|
14
|
+
const add = (name) => { if (!found.includes(name)) found.push(name); };
|
|
15
|
+
|
|
16
|
+
if (win.__REACT_DEVTOOLS_GLOBAL_HOOK__ || win.React) add('React');
|
|
17
|
+
if (win.__NEXT_DATA__) add('Next.js');
|
|
18
|
+
if (win.Vue || win.__VUE__) add('Vue');
|
|
19
|
+
if (win.__NUXT__) add('Nuxt');
|
|
20
|
+
if (win.ng || win.getAllAngularRootElements) add('Angular');
|
|
21
|
+
if (win.__svelte || win.__SVELTE_HMR__) add('Svelte');
|
|
22
|
+
if (win.Alpine) add('Alpine.js');
|
|
23
|
+
if (win.jQuery || win.$?.fn?.jquery) add('jQuery');
|
|
24
|
+
if (win.wp || win.wpApiSettings) add('WordPress');
|
|
25
|
+
if (win.prestashop) add('PrestaShop');
|
|
26
|
+
if (win.Shopify) add('Shopify');
|
|
27
|
+
|
|
28
|
+
const gen = doc.querySelector('meta[name="generator"]');
|
|
29
|
+
const genText = (gen && gen.content) || '';
|
|
30
|
+
for (const [needle, name] of [
|
|
31
|
+
['WordPress', 'WordPress'], ['PrestaShop', 'PrestaShop'],
|
|
32
|
+
['Joomla', 'Joomla'], ['Drupal', 'Drupal'], ['Hugo', 'Hugo'],
|
|
33
|
+
['Gatsby', 'Gatsby'], ['Astro', 'Astro'],
|
|
34
|
+
]) {
|
|
35
|
+
if (genText.includes(needle)) add(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const s of doc.querySelectorAll('script[src]')) {
|
|
39
|
+
const src = s.src || '';
|
|
40
|
+
if (src.includes('/@vite/')) add('Vite');
|
|
41
|
+
if (src.includes('webpack')) add('webpack');
|
|
42
|
+
if (src.includes('cdn.tailwindcss.com')) add('Tailwind');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
globalThis.__cbStackDetect = { detectStack };
|
|
49
|
+
|
|
50
|
+
// Esecuzione come content script: deposita il risultato per il service worker
|
|
51
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.document === document) {
|
|
52
|
+
try { window.__chromeBridge_stackDetect = detectStack(window, document); } catch {}
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funzioni pure per la telemetria del popup: ring buffer degli errori
|
|
3
|
+
* recenti e report diagnostico copiabile. Nessuna API chrome.* qui:
|
|
4
|
+
* il file è importato sia dal service worker sia dai unit test Node
|
|
5
|
+
* (side-effect import, export su globalThis).
|
|
6
|
+
*/
|
|
7
|
+
(() => {
|
|
8
|
+
function pushError(buf, entry, max = 5) {
|
|
9
|
+
const next = buf.concat([entry]);
|
|
10
|
+
return next.length > max ? next.slice(next.length - max) : next;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildDiagnostics(d) {
|
|
14
|
+
return JSON.stringify({
|
|
15
|
+
extension: d.extensionVersion,
|
|
16
|
+
server: d.serverVersion || 'unknown',
|
|
17
|
+
chrome: d.chromeVersion,
|
|
18
|
+
state: d.state,
|
|
19
|
+
port: d.port,
|
|
20
|
+
userScripts: d.userScriptsEnabled,
|
|
21
|
+
instrument: d.instrument,
|
|
22
|
+
toolCalls: d.toolCallCount,
|
|
23
|
+
lastTool: d.lastTool,
|
|
24
|
+
recentErrors: d.recentErrors,
|
|
25
|
+
}, null, 2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
globalThis.__cbTelemetry = { pushError, buildDiagnostics };
|
|
29
|
+
})();
|
package/install.sh
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
SERVER_ENTRY="$SCRIPT_DIR/server/index.js"
|
|
6
|
+
|
|
7
|
+
echo "=== Chrome Bridge Installer ==="
|
|
8
|
+
echo ""
|
|
9
|
+
|
|
10
|
+
# 1. Check Node.js
|
|
11
|
+
if ! command -v node &>/dev/null; then
|
|
12
|
+
echo "ERROR: Node.js is required but not installed."
|
|
13
|
+
echo "Install it from https://nodejs.org/ or via your package manager."
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
echo "[OK] Node.js $(node -v)"
|
|
17
|
+
|
|
18
|
+
# 2. Install npm dependencies
|
|
19
|
+
echo ""
|
|
20
|
+
echo "Installing dependencies..."
|
|
21
|
+
cd "$SCRIPT_DIR"
|
|
22
|
+
npm install --silent
|
|
23
|
+
echo "[OK] Dependencies installed"
|
|
24
|
+
|
|
25
|
+
# 3. Register MCP server in Claude Code
|
|
26
|
+
echo ""
|
|
27
|
+
if command -v claude &>/dev/null; then
|
|
28
|
+
echo "Registering MCP server in Claude Code..."
|
|
29
|
+
# -e CHROME_BRIDGE_CAPS=all: senza questo il default e caps=core (30 tool su 59)
|
|
30
|
+
# e chi installa non ha extract_table/audits, che i doc promettono.
|
|
31
|
+
claude mcp add --scope user chrome-bridge -e CHROME_BRIDGE_CAPS=all -- node "$SERVER_ENTRY" 2>/dev/null && \
|
|
32
|
+
echo "[OK] MCP server registered (scope: user, all 59 tools)" || \
|
|
33
|
+
echo "[SKIP] MCP server already registered or claude command failed"
|
|
34
|
+
else
|
|
35
|
+
echo "[SKIP] 'claude' CLI not found. Register manually:"
|
|
36
|
+
echo " claude mcp add --scope user chrome-bridge node $SERVER_ENTRY"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# 4. Preflight: porta e piattaforma
|
|
40
|
+
echo ""
|
|
41
|
+
if command -v ss &>/dev/null && ss -ltn 2>/dev/null | grep -q ':8765 '; then
|
|
42
|
+
echo "[NOTE] Port 8765 is already in use — a second chrome-bridge starts as a relay"
|
|
43
|
+
echo " and shares the existing bridge. Set CHROME_BRIDGE_PORT to change it."
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# 5. Chrome extension instructions
|
|
47
|
+
echo ""
|
|
48
|
+
echo "=== Chrome Extension Setup ==="
|
|
49
|
+
echo ""
|
|
50
|
+
if [ "$(hostname)" = "penguin" ]; then
|
|
51
|
+
# Su ChromeOS/Crostini un'estensione caricata da filesystem viene scartata a
|
|
52
|
+
# ogni reboot: il container non e montato quando Chrome parte.
|
|
53
|
+
echo " ChromeOS/Crostini detected — install from the Chrome Web Store instead"
|
|
54
|
+
echo " of Load unpacked (a filesystem-loaded extension is dropped on reboot):"
|
|
55
|
+
echo " https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb"
|
|
56
|
+
else
|
|
57
|
+
echo " 1. Open chrome://extensions in Chrome"
|
|
58
|
+
echo " 2. Enable 'Developer mode' (top right toggle)"
|
|
59
|
+
echo " 3. Click 'Load unpacked'"
|
|
60
|
+
echo " 4. Select: $SCRIPT_DIR/extension"
|
|
61
|
+
fi
|
|
62
|
+
echo ""
|
|
63
|
+
echo " Then enable 'Allow user scripts' in the extension Details page"
|
|
64
|
+
echo " (needed by execute_js and wait_for(condition=function))."
|
|
65
|
+
echo ""
|
|
66
|
+
echo "=== Done ==="
|
|
67
|
+
echo ""
|
|
68
|
+
echo "After loading the extension, restart Claude Code."
|
|
69
|
+
echo "The extension popup shows connection status."
|
|
70
|
+
echo "Test with: node $SCRIPT_DIR/test/test-devtools.js"
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chrome-bridge-mcp",
|
|
3
|
+
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
+
"version": "1.8.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
|
+
"main": "server/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"chrome-bridge-mcp": "server/index.js",
|
|
10
|
+
"chrome-bridge": "server/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node server/index.js",
|
|
14
|
+
"test": "node --test test/unit/*.test.js",
|
|
15
|
+
"test:unit": "node --test test/unit/*.test.js",
|
|
16
|
+
"test:e2e": "node test/test-devtools.js",
|
|
17
|
+
"measure": "node tools/measure-schema.mjs"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"chrome",
|
|
22
|
+
"browser-automation",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"chromeos",
|
|
25
|
+
"crostini",
|
|
26
|
+
"devtools",
|
|
27
|
+
"websocket",
|
|
28
|
+
"chrome-extension",
|
|
29
|
+
"accessibility",
|
|
30
|
+
"testing"
|
|
31
|
+
],
|
|
32
|
+
"author": "frsorrentino",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/frsorrentino/chrome-bridge.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/frsorrentino/chrome-bridge#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/frsorrentino/chrome-bridge/issues"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"server/",
|
|
44
|
+
"tools/",
|
|
45
|
+
"extension/",
|
|
46
|
+
"install.sh",
|
|
47
|
+
"README.md"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18.0.0"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
54
|
+
"node-html-parser": "^9.0.0",
|
|
55
|
+
"ws": "^8.18.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assertion su condizioni di pagina, con polling fino a timeout.
|
|
3
|
+
* Condivisa tra tool MCP (tools.js) e CLI/replay (cli.js): stessa semantica
|
|
4
|
+
* nei flussi interattivi e nei replay registrati.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MessageType } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
/** Pattern: "/.../" = regex, altrimenti substring. */
|
|
10
|
+
export function matchPattern(value, pattern) {
|
|
11
|
+
const s = String(value ?? '');
|
|
12
|
+
if (typeof pattern === 'string' && pattern.length > 2 && pattern.startsWith('/') && pattern.endsWith('/')) {
|
|
13
|
+
return new RegExp(pattern.slice(1, -1)).test(s);
|
|
14
|
+
}
|
|
15
|
+
return s.includes(String(pattern));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Valuta le condizioni una volta. Ritorna array di failure (vuoto = passed).
|
|
20
|
+
*
|
|
21
|
+
* @param {(type: string, params?: object) => Promise<object>} send
|
|
22
|
+
* @param {object} p - parametri assert (selector, state, text, count, url, title, tab_id)
|
|
23
|
+
*/
|
|
24
|
+
async function evaluate(send, p) {
|
|
25
|
+
const failures = [];
|
|
26
|
+
|
|
27
|
+
if (p.url != null || p.title != null) {
|
|
28
|
+
const tabs = await send(MessageType.GET_TABS, {});
|
|
29
|
+
const list = Array.isArray(tabs) ? tabs : [];
|
|
30
|
+
const tab = p.tab_id != null ? list.find((t) => t.id === p.tab_id) : list.find((t) => t.active);
|
|
31
|
+
if (!tab) {
|
|
32
|
+
failures.push(`tab ${p.tab_id ?? '(active)'} not found`);
|
|
33
|
+
} else {
|
|
34
|
+
if (p.url != null && !matchPattern(tab.url, p.url)) failures.push(`url "${tab.url}" does not match "${p.url}"`);
|
|
35
|
+
if (p.title != null && !matchPattern(tab.title, p.title)) failures.push(`title "${tab.title}" does not match "${p.title}"`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (p.selector != null) {
|
|
40
|
+
// limit: per count esatto serve vedere se esistono match oltre l'atteso
|
|
41
|
+
const limit = p.count != null ? p.count + 1 : (p.text != null || p.state === 'visible' ? 10 : 1);
|
|
42
|
+
const data = await send(MessageType.QUERY_DOM, { selector: p.selector, limit, tab_id: p.tab_id });
|
|
43
|
+
const els = data?.elements ?? [];
|
|
44
|
+
if (p.count != null) {
|
|
45
|
+
if (els.length !== p.count) failures.push(`count ${els.length >= limit ? `>=${limit}` : els.length} != ${p.count} for "${p.selector}"`);
|
|
46
|
+
} else if (els.length === 0) {
|
|
47
|
+
failures.push(`no element matches "${p.selector}"`);
|
|
48
|
+
}
|
|
49
|
+
if (p.state === 'visible' && els.length && !els.some((e) => e.rect?.width > 0 && e.rect?.height > 0)) {
|
|
50
|
+
failures.push(`"${p.selector}" matched but not visible`);
|
|
51
|
+
}
|
|
52
|
+
if (p.text != null && els.length && !els.some((e) => matchPattern(e.textContent, p.text))) {
|
|
53
|
+
failures.push(`text "${p.text}" not found in "${p.selector}"`);
|
|
54
|
+
}
|
|
55
|
+
} else if (p.text != null) {
|
|
56
|
+
// Pagina intera: find_text è substring-only, niente regex
|
|
57
|
+
const data = await send(MessageType.FIND_TEXT, { text: p.text, max_results: 1, tab_id: p.tab_id });
|
|
58
|
+
if ((data?.count ?? 0) === 0) failures.push(`text "${p.text}" not found on page`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return failures;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Esegue l'assert con retry fino a timeout. Ritorna { passed: true, waited_ms }
|
|
66
|
+
* o lancia Error col dettaglio dell'ultima valutazione fallita.
|
|
67
|
+
*/
|
|
68
|
+
export async function runAssert(send, params) {
|
|
69
|
+
const { timeout = 5000, interval = 300 } = params;
|
|
70
|
+
if (params.selector == null && params.text == null && params.url == null && params.title == null) {
|
|
71
|
+
throw new Error('assert requires at least one of: selector, text, url, title');
|
|
72
|
+
}
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
const deadline = start + timeout;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const failures = await evaluate(send, params);
|
|
77
|
+
if (!failures.length) return { passed: true, waited_ms: Date.now() - start };
|
|
78
|
+
if (Date.now() >= deadline) throw new Error(`Assertion failed after ${timeout}ms: ${failures.join('; ')}`);
|
|
79
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
80
|
+
}
|
|
81
|
+
}
|
package/server/cli.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI di chrome-bridge: stessi comandi dei tool MCP, via Bash.
|
|
5
|
+
*
|
|
6
|
+
* Si connette come client relay al server WebSocket già attivo (mai come
|
|
7
|
+
* primary: processo breve, non deve contendere la porta all'istanza MCP).
|
|
8
|
+
* Pensata per batch e output filtrabili con pipe:
|
|
9
|
+
*
|
|
10
|
+
* chrome-bridge tabs
|
|
11
|
+
* chrome-bridge read_console --tab-id 42 | grep -i error | head -5
|
|
12
|
+
* chrome-bridge js --code 'document.title'
|
|
13
|
+
* chrome-bridge screenshot --out /tmp/shot.png
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import WebSocket from 'ws';
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { basename, extname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { DEFAULT_PORT, MessageType, createCommand, getTimeout } from './protocol.js';
|
|
21
|
+
import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
|
|
22
|
+
import { runAssert } from './assertions.js';
|
|
23
|
+
import { checkLinksBatch } from './link-checker.js';
|
|
24
|
+
import { evaluateSecurityHeaders } from './security-headers.js';
|
|
25
|
+
import { toHar } from './har.js';
|
|
26
|
+
|
|
27
|
+
const INTERNAL_TYPES = new Set([
|
|
28
|
+
MessageType.RESULT, MessageType.ERROR, MessageType.PING, MessageType.PONG,
|
|
29
|
+
MessageType.EXT_INIT, MessageType.RELAY_INIT,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Comandi virtuali: logica lato CLI (come i corrispondenti tool MCP lato server)
|
|
33
|
+
const VIRTUAL_COMMANDS = new Set(['status', 'check_links', 'security_headers', 'replay', 'assert']);
|
|
34
|
+
|
|
35
|
+
const ALIASES = { tabs: 'get_tabs', js: 'execute_js', console: 'read_console', network: 'monitor_network', interactives: 'get_interactives' };
|
|
36
|
+
|
|
37
|
+
const VALID_COMMANDS = new Set([
|
|
38
|
+
...Object.values(MessageType).filter((t) => !INTERNAL_TYPES.has(t)),
|
|
39
|
+
...VIRTUAL_COMMANDS,
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// Opzioni consumate dalla CLI, mai inoltrate all'estensione
|
|
43
|
+
const CLI_OPTS = new Set(['out', 'format', 'max_chars']);
|
|
44
|
+
|
|
45
|
+
// La coercizione numerica cieca corrompeva i valori testuali: `--text 00185`
|
|
46
|
+
// diventava 185 (CAP, prefissi telefonici, codici con zeri iniziali digitati
|
|
47
|
+
// sbagliati in silenzio) e `--code 42` diventava un numero invece di sorgente
|
|
48
|
+
// JS. Solo le chiavi che sono davvero numeriche/booleane vengono convertite.
|
|
49
|
+
const NUMERIC_KEYS = new Set([
|
|
50
|
+
'tab_id', 'frame_id', 'limit', 'timeout', 'interval', 'max_length', 'max_chars',
|
|
51
|
+
'max_rows', 'max_items', 'max_scrolls', 'max_segments', 'segment_offset', 'max_selectors',
|
|
52
|
+
'delay', 'offset', 'scan_rows', 'width', 'height', 'x', 'y', 'level_num',
|
|
53
|
+
'status', 'zoom', 'depth', 'count', 'index', 'port', 'threshold', 'scale',
|
|
54
|
+
'latitude', 'longitude', 'accuracy', 'step_px', 'settle_ms', 'repeat',
|
|
55
|
+
]);
|
|
56
|
+
const BOOLEAN_KEYS = new Set([
|
|
57
|
+
'clear', 'stop', 'force', 'visible', 'visible_only', 'stitch', 'reset',
|
|
58
|
+
'attributes', 'childList', 'characterData', 'subtree', 'print_mode',
|
|
59
|
+
'include_cross_origin', 'headless', 'active', 'submit', 'accept', 'include_rect',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function coerce(raw, key) {
|
|
63
|
+
if (BOOLEAN_KEYS.has(key)) {
|
|
64
|
+
if (raw === 'true') return true;
|
|
65
|
+
if (raw === 'false') return false;
|
|
66
|
+
}
|
|
67
|
+
if (NUMERIC_KEYS.has(key) && /^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
68
|
+
// Chiavi non dichiarate: true/false restano booleani (comodo per i flag noti
|
|
69
|
+
// dei tool), tutto il resto resta stringa.
|
|
70
|
+
if (raw === 'true') return true;
|
|
71
|
+
if (raw === 'false') return false;
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseCliArgs(argv) {
|
|
76
|
+
const [cmdRaw, ...rest] = argv;
|
|
77
|
+
if (!cmdRaw) throw new Error('Unknown command: (none). Run with --help.');
|
|
78
|
+
const command = ALIASES[cmdRaw] ?? cmdRaw;
|
|
79
|
+
if (!VALID_COMMANDS.has(command)) throw new Error(`Unknown command: ${cmdRaw}. Run with --help.`);
|
|
80
|
+
|
|
81
|
+
const params = {};
|
|
82
|
+
const opts = {};
|
|
83
|
+
for (let i = 0; i < rest.length; i++) {
|
|
84
|
+
const arg = rest[i];
|
|
85
|
+
if (!arg.startsWith('--')) throw new Error(`Unexpected argument: ${arg} (flags are --key value)`);
|
|
86
|
+
const key = arg.slice(2).replaceAll('-', '_');
|
|
87
|
+
let value = true;
|
|
88
|
+
if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) {
|
|
89
|
+
value = coerce(rest[++i], key);
|
|
90
|
+
}
|
|
91
|
+
if (key === 'json') {
|
|
92
|
+
Object.assign(params, JSON.parse(value));
|
|
93
|
+
} else if (CLI_OPTS.has(key)) {
|
|
94
|
+
opts[key] = value;
|
|
95
|
+
} else {
|
|
96
|
+
params[key] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { command, params, opts };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Connessione relay ───────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function connect(port) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
107
|
+
const pending = new Map();
|
|
108
|
+
|
|
109
|
+
ws.on('open', () => {
|
|
110
|
+
ws.send(JSON.stringify({ type: MessageType.RELAY_INIT }));
|
|
111
|
+
resolve({
|
|
112
|
+
sendCommand(type, params = {}) {
|
|
113
|
+
return new Promise((res, rej) => {
|
|
114
|
+
const command = createCommand(type, params);
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
pending.delete(command.id);
|
|
117
|
+
rej(new Error(`Command ${type} timed out after ${getTimeout(type)}ms`));
|
|
118
|
+
}, getTimeout(type));
|
|
119
|
+
pending.set(command.id, { res, rej, timer });
|
|
120
|
+
ws.send(JSON.stringify(command));
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
close: () => ws.close(),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
ws.on('message', (raw) => {
|
|
128
|
+
let msg;
|
|
129
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
130
|
+
const entry = pending.get(msg.id);
|
|
131
|
+
if (!entry) return;
|
|
132
|
+
pending.delete(msg.id);
|
|
133
|
+
clearTimeout(entry.timer);
|
|
134
|
+
if (msg.type === MessageType.ERROR) entry.rej(new Error(msg.error || 'Unknown error from extension'));
|
|
135
|
+
else entry.res(msg.data);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
ws.on('error', (err) => {
|
|
139
|
+
reject(new Error(err.code === 'ECONNREFUSED'
|
|
140
|
+
? `chrome-bridge server not running on port ${port} — start a Claude Code session (MCP server) or run: npm start`
|
|
141
|
+
: err.message));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Output helpers ──────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const MIME_BY_EXT = {
|
|
149
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
150
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
151
|
+
'.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json',
|
|
152
|
+
'.zip': 'application/zip', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function printResult(text, opts) {
|
|
156
|
+
// Nessun troncamento quando l'output non va a un terminale (pipe verso
|
|
157
|
+
// jq/grep) o quando è JSON: la nota per umani rendeva il JSON non parsabile.
|
|
158
|
+
const isJson = (() => {
|
|
159
|
+
const t = text.trimStart();
|
|
160
|
+
if (!t.startsWith('{') && !t.startsWith('[')) return false;
|
|
161
|
+
try { JSON.parse(text); return true; } catch { return false; }
|
|
162
|
+
})();
|
|
163
|
+
const explicit = opts.max_chars !== undefined;
|
|
164
|
+
const max = explicit ? opts.max_chars : (process.stdout.isTTY && !isJson ? 20000 : 0);
|
|
165
|
+
if (max > 0 && text.length > max) {
|
|
166
|
+
text = text.slice(0, max) + `\n…[truncated, ${text.length - max} more chars — use --max-chars 0 for full output, or pipe through grep/head]`;
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(text + '\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function writeImages(images, outBase) {
|
|
172
|
+
const paths = [];
|
|
173
|
+
const dot = outBase.lastIndexOf('.');
|
|
174
|
+
const stem = dot > 0 ? outBase.slice(0, dot) : outBase;
|
|
175
|
+
const ext = dot > 0 ? outBase.slice(dot) : '.png';
|
|
176
|
+
for (let i = 0; i < images.length; i++) {
|
|
177
|
+
const path = images.length === 1 ? `${stem}${ext}` : `${stem}-${i + 1}${ext}`;
|
|
178
|
+
await writeFile(path, Buffer.from(images[i], 'base64'));
|
|
179
|
+
paths.push(path);
|
|
180
|
+
}
|
|
181
|
+
return paths;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Replay ──────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Sostituisce i placeholder {{key}} nelle stringhe di un albero di params. */
|
|
187
|
+
export function substituteVars(value, vars) {
|
|
188
|
+
if (typeof value === 'string') {
|
|
189
|
+
return value.replace(/\{\{(\w+)\}\}/g, (m, key) => (key in vars ? String(vars[key]) : m));
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(value)) return value.map((v) => substituteVars(v, vars));
|
|
192
|
+
if (value && typeof value === 'object') {
|
|
193
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substituteVars(v, vars)]));
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Legge un recording jsonl e ritorna gli step {command, params}. */
|
|
199
|
+
export function parseReplayFile(content) {
|
|
200
|
+
return content.split('\n').filter((l) => l.trim()).map((line, i) => {
|
|
201
|
+
let step;
|
|
202
|
+
try { step = JSON.parse(line); } catch { throw new Error(`Invalid JSON at line ${i + 1}`); }
|
|
203
|
+
if (!step.command) throw new Error(`Missing "command" at line ${i + 1}`);
|
|
204
|
+
return { command: step.command, params: step.params ?? {} };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function replay(client, params) {
|
|
209
|
+
if (!params.file) throw new Error('replay requires --file /path/to/flow.jsonl');
|
|
210
|
+
const vars = params.vars ? (typeof params.vars === 'string' ? JSON.parse(params.vars) : params.vars) : {};
|
|
211
|
+
const delay = params.delay ?? 0;
|
|
212
|
+
const stopOnError = params.stop_on_error ?? true;
|
|
213
|
+
const steps = parseReplayFile(await readFile(params.file, 'utf8'));
|
|
214
|
+
const out = [];
|
|
215
|
+
let failed = 0;
|
|
216
|
+
for (let i = 0; i < steps.length; i++) {
|
|
217
|
+
const { command, params: stepParams } = steps[i];
|
|
218
|
+
try {
|
|
219
|
+
const p = substituteVars(stepParams, vars);
|
|
220
|
+
const data = command === 'assert'
|
|
221
|
+
? await runAssert(client.sendCommand, p)
|
|
222
|
+
: await client.sendCommand(command, p);
|
|
223
|
+
const summary = JSON.stringify(data);
|
|
224
|
+
out.push(`${i + 1} ${command} ok ${summary.length > 120 ? summary.slice(0, 120) + '…' : summary}`);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
failed++;
|
|
227
|
+
out.push(`${i + 1} ${command} ERR ${err.message}`);
|
|
228
|
+
if (stopOnError) break;
|
|
229
|
+
}
|
|
230
|
+
if (delay && i < steps.length - 1) await new Promise((r) => setTimeout(r, delay));
|
|
231
|
+
}
|
|
232
|
+
out.push(`replay ${params.file}: ${out.length - failed}/${steps.length} ok${failed ? `, ${failed} failed` : ''}`);
|
|
233
|
+
if (failed) process.exitCode = 1;
|
|
234
|
+
return out.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Dispatch ────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
async function run(client, command, params, opts) {
|
|
240
|
+
if (command === 'replay') {
|
|
241
|
+
return replay(client, params);
|
|
242
|
+
}
|
|
243
|
+
if (command === 'assert') {
|
|
244
|
+
return JSON.stringify(await runAssert(client.sendCommand, params));
|
|
245
|
+
}
|
|
246
|
+
// Comandi virtuali
|
|
247
|
+
if (command === 'status') {
|
|
248
|
+
const tabs = await client.sendCommand(MessageType.GET_TABS);
|
|
249
|
+
return `server=ok extension=connected tabs=${Array.isArray(tabs) ? tabs.length : 0}`;
|
|
250
|
+
}
|
|
251
|
+
if (command === 'check_links') {
|
|
252
|
+
const { timeout, ...collectParams } = params;
|
|
253
|
+
const data = await client.sendCommand(MessageType.COLLECT_LINKS, collectParams);
|
|
254
|
+
const links = data.links ?? [];
|
|
255
|
+
const results = await checkLinksBatch(links, timeout ?? 5000);
|
|
256
|
+
const broken = results.filter((r) => r.broken).length;
|
|
257
|
+
if (opts.format === 'json') return JSON.stringify({ total: links.length, checked: results.length, broken, totalAnchors: data.totalAnchors, results });
|
|
258
|
+
return linksLines(results, { total: links.length, broken, anchors: data.totalAnchors });
|
|
259
|
+
}
|
|
260
|
+
if (command === 'security_headers') {
|
|
261
|
+
const data = await client.sendCommand(MessageType.GET_RESPONSE_HEADERS, params);
|
|
262
|
+
if (!data.available) return JSON.stringify(data);
|
|
263
|
+
const result = evaluateSecurityHeaders(data.headers, data.url);
|
|
264
|
+
result.status = data.status;
|
|
265
|
+
return JSON.stringify(result);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Comandi con file I/O lato CLI
|
|
269
|
+
if (command === 'upload_file') {
|
|
270
|
+
const buf = await readFile(params.path);
|
|
271
|
+
if (buf.length > 10 * 1024 * 1024) throw new Error(`File too large: ${buf.length} bytes (max 10MB)`);
|
|
272
|
+
const mime = params.mime_type || MIME_BY_EXT[extname(params.path).toLowerCase()] || 'application/octet-stream';
|
|
273
|
+
const data = await client.sendCommand(MessageType.UPLOAD_FILE, {
|
|
274
|
+
selector: params.selector, name: basename(params.path), mime_type: mime,
|
|
275
|
+
content_b64: buf.toString('base64'), tab_id: params.tab_id,
|
|
276
|
+
});
|
|
277
|
+
return JSON.stringify(data);
|
|
278
|
+
}
|
|
279
|
+
if (command === 'save_page') {
|
|
280
|
+
if (!opts.out) throw new Error('save_page requires --out /path/to/page.mhtml');
|
|
281
|
+
const data = await client.sendCommand(MessageType.SAVE_PAGE, params);
|
|
282
|
+
await writeFile(opts.out, Buffer.from(data.mhtml_b64, 'base64'));
|
|
283
|
+
return JSON.stringify({ saved: opts.out, size: data.size });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const data = await client.sendCommand(command, params);
|
|
287
|
+
|
|
288
|
+
// Comandi che restituiscono immagini → file su disco
|
|
289
|
+
if (command === 'screenshot' || command === 'element_screenshot') {
|
|
290
|
+
if (!data?.image) return JSON.stringify(data);
|
|
291
|
+
const [path] = await writeImages([data.image], opts.out ?? `chrome-bridge-shot-${Date.now()}.png`);
|
|
292
|
+
return `saved ${path}`;
|
|
293
|
+
}
|
|
294
|
+
if (command === 'full_page_screenshot') {
|
|
295
|
+
const images = data?.images ?? (data?.image ? [data.image] : data?.captures ?? []);
|
|
296
|
+
if (!images.length) return JSON.stringify(data);
|
|
297
|
+
const paths = await writeImages(images, opts.out ?? `chrome-bridge-fullpage-${Date.now()}.png`);
|
|
298
|
+
return `saved ${paths.join(' ')} (${data.totalCaptures} captures, scrollHeight=${data.scrollHeight}${data.truncated ? ', truncated' : ''})`;
|
|
299
|
+
}
|
|
300
|
+
if (command === 'screenshot_diff' && data?.diff_image) {
|
|
301
|
+
const { diff_image, ...rest } = data;
|
|
302
|
+
const [path] = await writeImages([diff_image], opts.out ?? `chrome-bridge-diff-${Date.now()}.png`);
|
|
303
|
+
return `saved ${path}\n${JSON.stringify(rest)}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Formati lines condivisi coi tool MCP
|
|
307
|
+
if (command === 'read_console' && opts.format !== 'json') {
|
|
308
|
+
const all = data?.messages ?? [];
|
|
309
|
+
return consoleLines(all.slice(-(params.limit ?? 50)), data?.count ?? all.length);
|
|
310
|
+
}
|
|
311
|
+
if (command === 'monitor_network' && opts.format !== 'json') {
|
|
312
|
+
const all = data?.requests ?? [];
|
|
313
|
+
const tail = all.slice(-(params.limit ?? 100));
|
|
314
|
+
if (opts.format === 'har') return JSON.stringify(toHar(tail));
|
|
315
|
+
return networkLines(tail, data?.count ?? all.length);
|
|
316
|
+
}
|
|
317
|
+
if (command === 'get_interactives' && opts.format !== 'json') {
|
|
318
|
+
return interactivesLines(data);
|
|
319
|
+
}
|
|
320
|
+
if (command === 'read_page' && typeof data === 'string') {
|
|
321
|
+
return data;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return JSON.stringify(data);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function printHelp() {
|
|
328
|
+
const commands = [...VALID_COMMANDS].sort().join(', ');
|
|
329
|
+
process.stdout.write(`chrome-bridge — CLI for the chrome-bridge WebSocket server
|
|
330
|
+
|
|
331
|
+
Usage:
|
|
332
|
+
chrome-bridge <command> [--flag value ...] [--json '{...}']
|
|
333
|
+
|
|
334
|
+
Options:
|
|
335
|
+
--out PATH Output file for screenshot/save_page commands
|
|
336
|
+
--format FMT lines (default) | json | har (monitor_network)
|
|
337
|
+
--max-chars N Truncate output at N chars (default 20000, 0 = unlimited)
|
|
338
|
+
--json '{...}' Merge raw JSON into command params
|
|
339
|
+
|
|
340
|
+
Flags map to command params: --tab-id 42 → tab_id, --visible-only false, --force.
|
|
341
|
+
Requires a running chrome-bridge server (MCP session or npm start); connects as relay.
|
|
342
|
+
|
|
343
|
+
Aliases: ${Object.entries(ALIASES).map(([a, c]) => `${a}→${c}`).join(', ')}
|
|
344
|
+
|
|
345
|
+
Commands:
|
|
346
|
+
${commands}
|
|
347
|
+
|
|
348
|
+
Examples:
|
|
349
|
+
chrome-bridge tabs
|
|
350
|
+
chrome-bridge navigate --url https://example.com
|
|
351
|
+
chrome-bridge read_console --tab-id 42 --level error | head -20
|
|
352
|
+
chrome-bridge js --code 'document.title'
|
|
353
|
+
chrome-bridge screenshot --out /tmp/shot.png
|
|
354
|
+
chrome-bridge check_links --scope same-origin
|
|
355
|
+
chrome-bridge replay --file ~/.config/chrome-bridge/recordings/login.jsonl --vars '{"user":"jane"}'
|
|
356
|
+
`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function main() {
|
|
360
|
+
const argv = process.argv.slice(2);
|
|
361
|
+
if (!argv.length || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
|
|
362
|
+
printHelp();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const { command, params, opts } = parseCliArgs(argv);
|
|
366
|
+
const port = parseInt(process.env.CHROME_BRIDGE_PORT || DEFAULT_PORT, 10);
|
|
367
|
+
const client = await connect(port);
|
|
368
|
+
try {
|
|
369
|
+
printResult(await run(client, command, params, opts), opts);
|
|
370
|
+
} finally {
|
|
371
|
+
client.close();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
376
|
+
main().catch((err) => {
|
|
377
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
});
|
|
380
|
+
}
|