chrome-bridge-mcp 1.11.0 → 1.13.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Chrome Bridge
2
2
 
3
- ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-147%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
3
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-169%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
4
4
 
5
5
  **Chrome Bridge is an MCP server that connects Claude Code to your real, logged-in Chrome browser — measured 2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome" extension on a form-filling task, with ~3× the toolset and no paid plan.**
6
6
 
7
- By using a local WebSocket bridge and a specialized Chrome extension, Chrome Bridge provides 60 web-development tools (navigation, DOM inspection, visual regression, audits, network mocking) and a dedicated headless instance for CI. It is self-hosted, local-only, and requires no paid plan.
7
+ By using a local WebSocket bridge and a specialized Chrome extension, Chrome Bridge provides 62 web-development tools (navigation, DOM inspection, visual regression, audits, network mocking) and a dedicated headless instance for CI. It is self-hosted, local-only, and requires no paid plan.
8
8
 
9
9
  ## Why Chrome Bridge?
10
10
 
@@ -31,12 +31,12 @@ unfavourable ones) and the harness limits are in
31
31
  - **Honest caveat:** per *turn*, Chrome Bridge costs slightly more than Claude in Chrome (41,985 vs 36,613 cache-read tokens; $0.0373 vs $0.0337). The win is in the number of turns.
32
32
 
33
33
  ### 2. Feature Comparison
34
- Chrome Bridge ships 60 specialized tools (31 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
34
+ Chrome Bridge ships 62 specialized tools (33 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
35
35
 
36
36
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
37
37
  |---|---|---|---|---|
38
38
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
39
- | **Tools** | **60** (31 core) | ~20 | ~50 | 23 core (71 total) |
39
+ | **Tools** | **62** (33 core) | ~20 | ~50 | 23 core (71 total) |
40
40
  | **Requires Paid Plan** | **No** | Yes (Pro+) | No | No |
41
41
  | **Network Mocking** | **Yes** (stub/headers) | No | No | Yes |
42
42
  | **Visual Regression** | **Yes** (`screenshot_diff`) | No | No | No |
@@ -63,7 +63,7 @@ Claude Code calls `navigate` (returning clickable element refs), `accessibility_
63
63
 
64
64
  ## Token-Efficient Design
65
65
 
66
- - **Opt-in Surface**: 31 core tools cost ≈7.9k tokens of `tools/list`, all 60 cost ≈14.9k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
66
+ - **Opt-in Surface**: 33 core tools cost ≈8.5k tokens of `tools/list`, all 62 cost ≈15.5k; specialized groups (audits, visual, network, storage, dom, files) are opt-in via `--caps`. That core figure was ≈3.8k in 1.8.0 and roughly doubled in 1.10.0: MCP annotations on every tool, rewritten descriptions, and a documented `.describe()` on all 254 parameters (coverage went from 35% to 100%). The trade is deliberate and it is not free — on the form benchmark it adds ≈8% to the cache-read tokens per turn — but the measured advantage was never prefix size, it was round trips: 2.75× fewer turns. Playwright MCP's 23 core tools measured ≈4.6k in July 2026, so on prefix size alone it is the leaner one. Measure ours with `npm run measure`.
67
67
  - **Act-from-Result**: Tools attach a capped preview of interactive elements with short refs. Actions only report a `page_changed` delta when the URL or title actually changes.
68
68
  - **Optimized Media**: Screenshots are downscaled to ≤1568px. Full-page captures are sliced into readable segments.
69
69
  - **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
@@ -84,10 +84,10 @@ Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
84
84
 
85
85
  The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome Extension** (MV3) executes commands via Chrome APIs. User scripts (`execute_js`) run via `chrome.userScripts.execute()`, requiring the "Allow user scripts" toggle in extension settings.
86
86
 
87
- ## Tools (60 total)
87
+ ## Tools (62 total)
88
88
 
89
- ### Core & Navigation (7)
90
- `get_status`, `get_tabs`, `create_tab`, `navigate`, `tab_action`, `get_frames`, `screenshot`.
89
+ ### Core & Navigation (9)
90
+ `get_status`, `get_tabs`, `create_tab`, `navigate`, `tab_action`, `move_tab` (between windows), `tile_windows` (split one monitor evenly), `get_frames`, `screenshot`.
91
91
 
92
92
  ### Interaction (11)
93
93
  `click`, `type_text`, `fill_form`, `hover`, `press_key`, `scroll`, `drag_and_drop`, `upload_file`, `dismiss_overlays`, `handle_dialogs`, `clipboard`.
@@ -132,7 +132,7 @@ Configure the server via environment variables (or the matching CLI flags):
132
132
  - `CHROME_BRIDGE_PORT`: Default `8765`.
133
133
  - `CHROME_BRIDGE_HOST` / `--host`: Bind address, default `127.0.0.1`. Set to `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and pair it with a token.
134
134
  - `CHROME_BRIDGE_TOKEN`: Shared secret required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind is not loopback.
135
- - `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (31 tools). `get_status` reports `caps_active` and `caps_available`.
135
+ - `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (33 tools). `get_status` reports `caps_active` and `caps_available`.
136
136
 
137
137
  Run `node tools/measure-schema.mjs` to see the exact schema cost of the active set.
138
138
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Divide un'area rettangolare in `count` tessere che la riempiono per intero.
3
+ *
4
+ * Funzione pura, tenuta fuori dal service worker perché è l'unica parte con una
5
+ * geometria da sbagliare: il resto del tool è una sequenza di
6
+ * chrome.windows.update.
7
+ *
8
+ * Sul resto della divisione intera: 3 finestre su 1000px fanno 333 e avanza 1.
9
+ * Buttarlo lascia una striscia di scrivania scoperta, che è esattamente ciò che
10
+ * l'affiancamento dovrebbe eliminare. Qui il resto viene distribuito un pixel
11
+ * per volta sulle prime tessere: le larghezze differiscono al massimo di 1px e
12
+ * lo spazio resta pieno.
13
+ */
14
+
15
+ // Ripartisce `total` in `parts` interi che sommano esattamente a `total`.
16
+ function split(total, parts) {
17
+ const base = Math.floor(total / parts);
18
+ const remainder = total - base * parts;
19
+ return Array.from({ length: parts }, (_, i) => base + (i < remainder ? 1 : 0));
20
+ }
21
+
22
+ // Righe e colonne per una griglia il più quadrata possibile: con 5 finestre
23
+ // preferisce 3×2 a 5×1, che su uno schermo largo lascerebbe strisce inutilizzabili.
24
+ function gridShape(count) {
25
+ const cols = Math.ceil(Math.sqrt(count));
26
+ const rows = Math.ceil(count / cols);
27
+ return { cols, rows };
28
+ }
29
+
30
+ export function computeTiles({ count, area, layout = 'grid', padding = 0 }) {
31
+ if (!count || count < 1) return [];
32
+
33
+ const box = {
34
+ left: area.left + padding,
35
+ top: area.top + padding,
36
+ width: area.width - padding * 2,
37
+ height: area.height - padding * 2,
38
+ };
39
+
40
+ let rows;
41
+ let colsPerRow;
42
+ if (layout === 'columns') {
43
+ rows = 1;
44
+ colsPerRow = [count];
45
+ } else if (layout === 'rows') {
46
+ rows = count;
47
+ colsPerRow = Array.from({ length: count }, () => 1);
48
+ } else {
49
+ const shape = gridShape(count);
50
+ rows = shape.rows;
51
+ // L'ultima riga può essere incompleta: le sue tessere si allargano a
52
+ // riempirla invece di lasciare un buco a destra.
53
+ colsPerRow = Array.from({ length: rows }, (_, r) => {
54
+ const remaining = count - r * shape.cols;
55
+ return Math.min(shape.cols, remaining);
56
+ });
57
+ }
58
+
59
+ const rowHeights = split(box.height, rows);
60
+ const tiles = [];
61
+ let top = box.top;
62
+
63
+ for (let r = 0; r < rows; r++) {
64
+ const cols = colsPerRow[r];
65
+ const colWidths = split(box.width, cols);
66
+ let left = box.left;
67
+ for (let c = 0; c < cols; c++) {
68
+ tiles.push({ left, top, width: colWidths[c], height: rowHeights[r] });
69
+ left += colWidths[c];
70
+ }
71
+ top += rowHeights[r];
72
+ }
73
+
74
+ return tiles;
75
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.11.0",
5
- "description": "Bridge your browser to Claude Code: 60 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
4
+ "version": "1.13.0",
5
+ "description": "Bridge your browser to Claude Code: 62 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
6
6
  "homepage_url": "https://github.com/frsorrentino/chrome-bridge",
7
7
  "minimum_chrome_version": "135",
8
8
  "permissions": [
@@ -7,6 +7,7 @@
7
7
 
8
8
  import './telemetry.js';
9
9
  import { buildMarkdown } from './lib/page-markdown.js';
10
+ import { computeTiles } from './lib/tile-layout.js';
10
11
  const { pushError } = globalThis.__cbTelemetry;
11
12
 
12
13
  const DEFAULT_PORT = 8765;
@@ -386,6 +387,10 @@ async function executeCommand(msg) {
386
387
  return await cmdGetFrames(params);
387
388
  case 'tab_action':
388
389
  return await cmdTabAction(params);
390
+ case 'move_tab':
391
+ return await cmdMoveTab(params);
392
+ case 'tile_windows':
393
+ return await cmdTileWindows(params);
389
394
  case 'upload_file':
390
395
  return await cmdUploadFile(params);
391
396
  case 'wait_for_navigation':
@@ -930,6 +935,142 @@ async function cmdCreateTab({ url, active = true }) {
930
935
 
931
936
  // --- tab_action ---
932
937
 
938
+ // --- move_tab ---
939
+
940
+ /**
941
+ * Sposta una scheda in un'altra finestra senza ricrearla: la scheda conserva id,
942
+ * cronologia e stato della pagina.
943
+ *
944
+ * Motivo per cui esiste: sul Terminale ChromeOS garcon apre ogni sessione in una
945
+ * finestra separata, e consolidarle richiede di spostare schede
946
+ * `chrome-untrusted://terminal`. Su quello schema `chrome.tabs.create` è vietato;
947
+ * `move` non ricrea nulla, quindi potrebbe essere permesso. Se Chrome rifiuta, il
948
+ * messaggio viene propagato **testuale**: è il dato che si sta cercando, e
949
+ * riscriverlo lo distruggerebbe.
950
+ */
951
+ // --- tile_windows ---
952
+
953
+ /**
954
+ * Affianca le finestre di Chrome su un monitor, dividendone l'area utile in
955
+ * parti uguali che non lasciano spazi.
956
+ *
957
+ * L'area del monitor si legge da `screen.availLeft/availTop/availWidth/
958
+ * availHeight` eseguito in una scheda di una finestra che ci sta sopra. La via
959
+ * "giusta" sarebbe chrome.system.display, ma richiede un permesso install-time:
960
+ * su un'estensione già pubblicata significa una nuova revisione e il possibile
961
+ * ri-consenso degli utenti, per un dato che il DOM offre gratis.
962
+ *
963
+ * Il prezzo di questa scelta: serve almeno una scheda scriptabile fra le
964
+ * finestre bersaglio. Una finestra con sole schede chrome:// o
965
+ * chrome-untrusted:// non può fornire l'area, e il tool lo dice invece di
966
+ * ripiegare su misure inventate.
967
+ */
968
+ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types }) {
969
+ const types = include_types?.length ? include_types : ['normal'];
970
+ const all = await chrome.windows.getAll({ populate: true });
971
+
972
+ let targets = all.filter((w) => types.includes(w.type));
973
+ if (window_ids?.length) targets = all.filter((w) => window_ids.includes(w.id));
974
+ if (!targets.length) throw new Error(`Nessuna finestra da affiancare (tipi: ${types.join(', ')})`);
975
+
976
+ // Il monitor è quello della finestra di riferimento: su più schermi non
977
+ // esiste "lo schermo" senza dire quale.
978
+ const reference = reference_window_id
979
+ ? targets.find((w) => w.id === reference_window_id) ?? all.find((w) => w.id === reference_window_id)
980
+ : targets.find((w) => w.focused) ?? targets[0];
981
+ if (!reference) throw new Error(`Finestra di riferimento ${reference_window_id} non trovata`);
982
+
983
+ // Prima scheda scriptabile fra le finestre bersaglio, partendo dalla
984
+ // riferimento: le pagine interne di Chrome non accettano injection.
985
+ const candidates = [reference, ...targets.filter((w) => w.id !== reference.id)]
986
+ .flatMap((w) => (w.tabs || []).map((t) => ({ windowId: w.id, tabId: t.id, url: t.url || '' })))
987
+ .filter((t) => /^(https?|file):/i.test(t.url));
988
+
989
+ let area = null;
990
+ const failures = [];
991
+ for (const cand of candidates) {
992
+ try {
993
+ const [res] = await chrome.scripting.executeScript({
994
+ target: { tabId: cand.tabId },
995
+ func: () => ({
996
+ left: screen.availLeft ?? 0,
997
+ top: screen.availTop ?? 0,
998
+ width: screen.availWidth,
999
+ height: screen.availHeight,
1000
+ }),
1001
+ world: 'MAIN',
1002
+ });
1003
+ if (res?.result?.width) { area = res.result; break; }
1004
+ } catch (e) {
1005
+ failures.push(`${cand.url.slice(0, 40)}: ${e.message}`);
1006
+ }
1007
+ }
1008
+ if (!area) {
1009
+ throw new Error(
1010
+ 'Impossibile leggere l\'area del monitor: nessuna scheda scriptabile fra le finestre bersaglio '
1011
+ + '(le pagine chrome:// e chrome-untrusted:// non lo permettono). '
1012
+ + `Apri una pagina http(s) in una di esse e riprova.${failures.length ? ' Tentativi: ' + failures.join(' | ') : ''}`,
1013
+ );
1014
+ }
1015
+
1016
+ const tiles = computeTiles({ count: targets.length, area, layout, padding });
1017
+ const results = [];
1018
+
1019
+ for (let i = 0; i < targets.length; i++) {
1020
+ const w = targets[i];
1021
+ const tile = tiles[i];
1022
+ try {
1023
+ // Una finestra massimizzata accetta i bounds e li ignora: va riportata a
1024
+ // 'normal' prima, o la chiamata riesce senza che nulla si muova.
1025
+ if (w.state !== 'normal') {
1026
+ await chrome.windows.update(w.id, { state: 'normal' });
1027
+ }
1028
+ const updated = await chrome.windows.update(w.id, tile);
1029
+ const actual = { left: updated.left, top: updated.top, width: updated.width, height: updated.height };
1030
+ results.push({
1031
+ window_id: w.id,
1032
+ type: w.type,
1033
+ requested: tile,
1034
+ actual,
1035
+ // Il window manager può rifiutare in silenzio: senza il confronto, una
1036
+ // finestra rimasta ferma passerebbe per affiancata.
1037
+ applied: actual.left === tile.left && actual.top === tile.top
1038
+ && actual.width === tile.width && actual.height === tile.height,
1039
+ });
1040
+ } catch (e) {
1041
+ results.push({ window_id: w.id, type: w.type, requested: tile, error: e.message });
1042
+ }
1043
+ }
1044
+
1045
+ return {
1046
+ monitor: area,
1047
+ layout,
1048
+ windows: results.length,
1049
+ applied: results.filter((r) => r.applied).length,
1050
+ results,
1051
+ };
1052
+ }
1053
+
1054
+ async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
1055
+ if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
1056
+ if (typeof window_id !== 'number') throw new Error('Missing required parameter: window_id');
1057
+
1058
+ const before = await chrome.tabs.get(tab_id);
1059
+ const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
1060
+ const tab = Array.isArray(moved) ? moved[0] : moved;
1061
+
1062
+ return {
1063
+ moved: tab_id,
1064
+ url: before.url,
1065
+ from_window: before.windowId,
1066
+ to_window: tab?.windowId ?? window_id,
1067
+ index: tab?.index ?? index,
1068
+ // Chrome può accettare la chiamata e lasciare la scheda dov'era: senza questo
1069
+ // il chiamante vedrebbe un successo per un'operazione non avvenuta.
1070
+ same_window: before.windowId === (tab?.windowId ?? window_id),
1071
+ };
1072
+ }
1073
+
933
1074
  async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
934
1075
  if (!action) throw new Error('Missing required parameter: action');
935
1076
  const tabId = await resolveTabId(tab_id);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "chrome-bridge-mcp",
3
3
  "mcpName": "io.github.frsorrentino/chrome-bridge",
4
- "version": "1.11.0",
4
+ "version": "1.13.0",
5
5
  "type": "module",
6
- "description": "MCP server with 60 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
6
+ "description": "MCP server with 62 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
8
8
  "bin": {
9
9
  "chrome-bridge-mcp": "server/index.js",
@@ -40,6 +40,8 @@ export const MessageType = Object.freeze({
40
40
  PRESS_KEY: 'press_key',
41
41
  GET_FRAMES: 'get_frames',
42
42
  TAB_ACTION: 'tab_action',
43
+ MOVE_TAB: 'move_tab',
44
+ TILE_WINDOWS: 'tile_windows',
43
45
  UPLOAD_FILE: 'upload_file',
44
46
  WAIT_FOR_NAVIGATION: 'wait_for_navigation',
45
47
  WAIT_FOR_NETWORK_IDLE: 'wait_for_network_idle',
@@ -82,7 +84,7 @@ export const MessageType = Object.freeze({
82
84
  });
83
85
 
84
86
  // Versione
85
- export const VERSION = '1.11.0';
87
+ export const VERSION = '1.13.0';
86
88
 
87
89
  // Configurazione
88
90
  export const DEFAULT_PORT = 8765;
@@ -133,6 +135,7 @@ export function getTimeout(type) {
133
135
  || type === MessageType.MANAGE_DOWNLOADS
134
136
  || type === MessageType.SAVE_PAGE
135
137
  || type === MessageType.HTTP_REQUEST
138
+ || type === MessageType.TILE_WINDOWS
136
139
  || type === MessageType.WAIT_FOR_FUNCTION
137
140
  || type === MessageType.SCROLL_UNTIL) return 60000;
138
141
  return COMMAND_TIMEOUT_MS;
package/server/tools.js CHANGED
@@ -188,8 +188,9 @@ export const TOOL_CAPS = {
188
188
  // Parametri ubiqui: un solo testo, così `tab_id` non significa una cosa in un
189
189
  // tool e un'altra nel gemello. test/unit/tool-parameters.test.js lo verifica.
190
190
  // save_to: il payload va su disco e nel contesto resta solo il percorso più un
191
- // sommario. L'idea è di superpowers-chrome, che scrive automaticamente a ogni
192
- // azione; qui è opt-in, perché scrivere file per chi non li leggerà è una tassa.
191
+ // sommario. Deliberatamente opt-in per chiamata e non automatico dopo ogni
192
+ // azione: scrivere file per chi non li leggerà è una tassa, e il server non
193
+ // controlla la directory di lavoro del client.
193
194
  const saveToField = (what) => z.string().optional()
194
195
  .describe(`Absolute path: write ${what} there and return the path instead of the content`);
195
196
 
@@ -288,6 +289,8 @@ export const TOOL_ANNOTATIONS = {
288
289
  set_zoom: rw({ idempotent: true }),
289
290
  viewport_resize: rw({ idempotent: true }),
290
291
  create_tab: rw({ open: true }),
292
+ move_tab: rw({ idempotent: true }), // rispostare dove è già = stesso esito
293
+ tile_windows: rw({ idempotent: true }),
291
294
  navigate: rw({ idempotent: true, open: true }),
292
295
  tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
293
296
 
@@ -1730,6 +1733,47 @@ export function registerTools(server, wsManager, caps = 'all') {
1730
1733
  }
1731
1734
  );
1732
1735
 
1736
+ // --- move_tab ---
1737
+ server.tool(
1738
+ 'move_tab',
1739
+ 'Move an existing tab into another window (chrome.tabs.move), e.g. to consolidate windows that were '
1740
+ + 'each opened separately. Does not create or close anything: the tab keeps its id, its history and its '
1741
+ + 'page state. Both windows must be normal browser windows — Chrome refuses to move a tab into a popup or '
1742
+ + 'an app window, and the refusal is reported verbatim rather than retried.',
1743
+ {
1744
+ tab_id: z.number().describe('Tab to move; get it from get_tabs'),
1745
+ window_id: z.number().describe('Destination window; get_tabs reports windowId for every tab'),
1746
+ index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
1747
+ },
1748
+ async ({ tab_id, window_id, index }) => {
1749
+ const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, index: index ?? -1 });
1750
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1751
+ }
1752
+ );
1753
+
1754
+ // --- tile_windows ---
1755
+ server.tool(
1756
+ 'tile_windows',
1757
+ 'Tile Chrome windows over one monitor, splitting its usable area into equal parts that leave no gap. '
1758
+ + 'Only Chrome windows: an extension cannot touch other applications. The monitor is chosen by pointing at '
1759
+ + 'a window already on it, since the work area is read from a page there — so at least one target window '
1760
+ + 'needs a scriptable tab (a chrome:// or chrome-untrusted:// tab cannot provide it). Maximized windows are '
1761
+ + 'restored first, because a maximized window accepts bounds and ignores them.',
1762
+ {
1763
+ window_ids: z.array(z.number()).optional().describe('Windows to tile; omitted = every normal window on the reference monitor'),
1764
+ reference_window_id: z.number().optional().describe('Window whose monitor is used; omitted = the focused one'),
1765
+ layout: z.enum(['grid', 'columns', 'rows']).optional().default('grid').describe('columns splits left to right, rows top to bottom, grid keeps tiles as square as it can'),
1766
+ padding: z.number().optional().default(0).describe('Px of empty margin kept inside the work area'),
1767
+ include_types: z.array(z.enum(['normal', 'popup', 'app'])).optional().describe('Window types to include; omitted = normal only'),
1768
+ },
1769
+ async ({ window_ids, reference_window_id, layout, padding, include_types }) => {
1770
+ const data = await send(MessageType.TILE_WINDOWS, {
1771
+ window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types,
1772
+ });
1773
+ return { content: [{ type: 'text', text: jsonText(data) }] };
1774
+ }
1775
+ );
1776
+
1733
1777
  // --- set_zoom ---
1734
1778
  server.tool(
1735
1779
  'set_zoom',
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Carica e pubblica lo zip dell'estensione sul Chrome Web Store via API.
4
+ *
5
+ * Esiste per togliere di mezzo il TOTP: la dev console non è automatizzabile
6
+ * (la gallery non è scriptabile da un'estensione, e via CDP serve comunque il
7
+ * login Google completo), mentre l'API vuole solo un refresh token revocabile.
8
+ *
9
+ * Credenziali in ~/.config/chrome-bridge/cws.json (chmod 600), MAI nel repo:
10
+ * { "client_id": "...", "client_secret": "...", "refresh_token": "...", "item_id": "..." }
11
+ * Sovrascrivibili da CWS_CLIENT_ID / CWS_CLIENT_SECRET / CWS_REFRESH_TOKEN / CWS_ITEM_ID.
12
+ *
13
+ * Uso:
14
+ * node tools/cws-upload.mjs dist/chrome-bridge-extension-1.11.0.zip # carica come bozza
15
+ * node tools/cws-upload.mjs dist/....zip --publish # carica e pubblica
16
+ * node tools/cws-upload.mjs --status # stato della bozza sullo store
17
+ * node tools/cws-upload.mjs --auth [porta] # URL di consenso + cattura del codice sul loopback
18
+ * node tools/cws-upload.mjs --exchange <code> [--redirect <uri>] # code → refresh token
19
+ */
20
+ import { createServer } from 'node:http';
21
+ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join, dirname } from 'node:path';
24
+
25
+ const CONFIG = join(homedir(), '.config', 'chrome-bridge', 'cws.json');
26
+ const SCOPE = 'https://www.googleapis.com/auth/chromewebstore';
27
+ // Google ha dismesso il redirect fuori banda (urn:ietf:wg:oauth:2.0:oob): un
28
+ // client nuovo che lo usa riceve "Errore 400: invalid_request". Per i client
29
+ // desktop la strada supportata è il loopback su una porta qualsiasi, che qui
30
+ // viene aperta al volo e chiusa appena arriva il codice.
31
+ function buildAuthUrl(clientId, redirectUri) {
32
+ return 'https://accounts.google.com/o/oauth2/auth?' + new URLSearchParams({
33
+ client_id: clientId,
34
+ redirect_uri: redirectUri,
35
+ response_type: 'code',
36
+ scope: SCOPE,
37
+ access_type: 'offline',
38
+ prompt: 'consent',
39
+ });
40
+ }
41
+
42
+ // Attende un solo codice sul loopback e poi chiude: nessun server che resta su.
43
+ function waitForCode(port) {
44
+ return new Promise((resolve, reject) => {
45
+ const server = createServer((req, res) => {
46
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
47
+ const code = url.searchParams.get('code');
48
+ const err = url.searchParams.get('error');
49
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
50
+ res.end(`<h2>${code ? 'Autorizzato. Puoi chiudere questa scheda.' : 'Errore: ' + (err || 'nessun codice')}</h2>`);
51
+ server.close();
52
+ if (code) resolve(code); else reject(new Error(err || 'nessun codice nella risposta'));
53
+ });
54
+ server.on('error', reject);
55
+ server.listen(port, '127.0.0.1');
56
+ setTimeout(() => { server.close(); reject(new Error('nessuna autorizzazione entro 5 minuti')); }, 300000);
57
+ });
58
+ }
59
+
60
+ export async function loadConfig(env = process.env) {
61
+ let file = {};
62
+ try { file = JSON.parse(await readFile(CONFIG, 'utf8')); } catch { /* prima esecuzione */ }
63
+ return {
64
+ client_id: env.CWS_CLIENT_ID || file.client_id,
65
+ client_secret: env.CWS_CLIENT_SECRET || file.client_secret,
66
+ refresh_token: env.CWS_REFRESH_TOKEN || file.refresh_token,
67
+ item_id: env.CWS_ITEM_ID || file.item_id,
68
+ };
69
+ }
70
+
71
+ export function requireFields(cfg, fields) {
72
+ const missing = fields.filter((f) => !cfg[f]);
73
+ if (missing.length) {
74
+ throw new Error(`Manca ${missing.join(', ')} — mettili in ${CONFIG} o nelle variabili CWS_*`);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Il Web Store risponde 200 anche quando il caricamento è fallito: l'esito vero
80
+ * è in uploadState, e itemError porta il motivo. Trattare 200 come successo è
81
+ * il modo di credere di aver pubblicato senza averlo fatto.
82
+ */
83
+ export function interpretUpload(body) {
84
+ const state = body?.uploadState;
85
+ if (state === 'SUCCESS') return { ok: true, state };
86
+ const reasons = (body?.itemError || []).map((e) => e.error_detail || e.errorDetail || JSON.stringify(e));
87
+ return { ok: false, state: state ?? 'UNKNOWN', reasons };
88
+ }
89
+
90
+ /**
91
+ * Distingue un'attesa da un guasto.
92
+ *
93
+ * Se un ritentativo periodico tratta "c'è una versione in revisione" come
94
+ * errore, ogni giro produce una notifica rossa per una situazione normale — e in
95
+ * due giorni nessuno guarda più le notifiche, incluse quelle vere.
96
+ */
97
+ export function classifyFailure(reasons = []) {
98
+ const text = reasons.join(' ').toLowerCase();
99
+ const waiting = [
100
+ 'in pending review',
101
+ 'is in review',
102
+ 'ready to publish',
103
+ 'version number is the same as the published one',
104
+ ];
105
+ return waiting.some((w) => text.includes(w)) ? 'locked' : 'error';
106
+ }
107
+
108
+ export function interpretPublish(body) {
109
+ const status = body?.status || [];
110
+ const ok = status.includes('OK') || status.includes('PUBLISHED_WITH_FRICTION_WARNING');
111
+ return { ok, status, detail: body?.statusDetail || [] };
112
+ }
113
+
114
+ async function exchange(cfg, code, redirectUri) {
115
+ const res = await fetch('https://oauth2.googleapis.com/token', {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
118
+ body: new URLSearchParams({
119
+ client_id: cfg.client_id, client_secret: cfg.client_secret,
120
+ code, grant_type: 'authorization_code', redirect_uri: redirectUri,
121
+ }),
122
+ });
123
+ const body = await res.json();
124
+ if (!body.refresh_token) throw new Error(`Nessun refresh_token: ${JSON.stringify(body)}`);
125
+ return body.refresh_token;
126
+ }
127
+
128
+ async function saveRefreshToken(cfg, refresh_token) {
129
+ await mkdir(dirname(CONFIG), { recursive: true });
130
+ await writeFile(CONFIG, JSON.stringify({ ...cfg, refresh_token }, null, 2), { mode: 0o600 });
131
+ console.log(`refresh_token salvato in ${CONFIG} (chmod 600)`);
132
+ }
133
+
134
+ async function accessToken(cfg) {
135
+ const res = await fetch('https://oauth2.googleapis.com/token', {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
138
+ body: new URLSearchParams({
139
+ client_id: cfg.client_id,
140
+ client_secret: cfg.client_secret,
141
+ refresh_token: cfg.refresh_token,
142
+ grant_type: 'refresh_token',
143
+ }),
144
+ });
145
+ const body = await res.json();
146
+ if (!res.ok || !body.access_token) {
147
+ // invalid_grant qui significa quasi sempre consent screen in "Testing":
148
+ // in quello stato Google scade i refresh token dopo 7 giorni.
149
+ throw new Error(`Token refresh fallito (${res.status}): ${JSON.stringify(body)}`);
150
+ }
151
+ return body.access_token;
152
+ }
153
+
154
+ async function main() {
155
+ const argv = process.argv.slice(2);
156
+ const cfg = await loadConfig();
157
+
158
+ if (argv.includes('--auth')) {
159
+ requireFields(cfg, ['client_id', 'client_secret']);
160
+ const portArg = argv[argv.indexOf('--auth') + 1];
161
+ const port = Number(portArg) > 0 ? Number(portArg) : 8899;
162
+ const redirect = `http://localhost:${port}`;
163
+ console.log(buildAuthUrl(cfg.client_id, redirect));
164
+ console.error(`in attesa del codice su ${redirect} …`);
165
+
166
+ const code = await waitForCode(port);
167
+ const token = await exchange(cfg, code, redirect);
168
+ await saveRefreshToken(cfg, token);
169
+ return;
170
+ }
171
+
172
+ const exIdx = argv.indexOf('--exchange');
173
+ if (exIdx !== -1) {
174
+ requireFields(cfg, ['client_id', 'client_secret']);
175
+ const code = argv[exIdx + 1];
176
+ if (!code) throw new Error('Uso: --exchange <codice> [--redirect <uri>]');
177
+ const rIdx = argv.indexOf('--redirect');
178
+ const redirect = rIdx !== -1 ? argv[rIdx + 1] : 'http://localhost:8899';
179
+ await saveRefreshToken(cfg, await exchange(cfg, code, redirect));
180
+ return;
181
+ }
182
+
183
+ if (argv.includes('--publish-only')) {
184
+ // Serve a distinguere due stati che l'API confonde in un solo messaggio di
185
+ // errore: "in revisione" (non si può fare nulla, si aspetta) e "pronta da
186
+ // pubblicare" (la revisione è passata e manca solo questa chiamata).
187
+ requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
188
+ const token = await accessToken(cfg);
189
+ const res = await fetch(`https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}/publish`, {
190
+ method: 'POST',
191
+ headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2', 'Content-Length': '0' },
192
+ });
193
+ const body = await res.json();
194
+ console.log(JSON.stringify({ http: res.status, ...body }, null, 2));
195
+ return;
196
+ }
197
+
198
+ if (argv.includes('--status')) {
199
+ requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
200
+ const token = await accessToken(cfg);
201
+ const res = await fetch(
202
+ `https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}?projection=DRAFT`,
203
+ { headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2' } },
204
+ );
205
+ const body = await res.json();
206
+ // uploadState dice se la bozza esiste e in che stato è; crxVersion dice quale
207
+ // versione è nel cassetto, che è l'unico modo di distinguere "in revisione"
208
+ // da "mai caricata" senza guardare la vetrina.
209
+ console.log(JSON.stringify({ http: res.status, ...body }, null, 2));
210
+ return;
211
+ }
212
+
213
+ const zip = argv.find((a) => a.endsWith('.zip'));
214
+ if (!zip) throw new Error('Indica lo zip da caricare, o usa --auth / --exchange');
215
+ requireFields(cfg, ['client_id', 'client_secret', 'refresh_token', 'item_id']);
216
+
217
+ const size = (await stat(zip)).size;
218
+ const token = await accessToken(cfg);
219
+
220
+ const up = await fetch(`https://www.googleapis.com/upload/chromewebstore/v1.1/items/${cfg.item_id}`, {
221
+ method: 'PUT',
222
+ headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2' },
223
+ body: await readFile(zip),
224
+ });
225
+ const upBody = await up.json();
226
+ const upRes = interpretUpload(upBody);
227
+ console.log(`upload ${zip} (${size} B): ${upRes.ok ? 'SUCCESS' : `FALLITO [${upRes.state}] ${upRes.reasons.join(' | ')}`}`);
228
+ if (!upRes.ok) {
229
+ // 2 = si riproverà, 1 = serve una persona. Il ritentativo periodico
230
+ // distingue i due casi da qui.
231
+ const kind = classifyFailure(upRes.reasons);
232
+ console.log(`esito: ${kind === 'locked' ? 'ATTESA (si ritenta più tardi)' : 'ERRORE (serve intervento)'}`);
233
+ process.exit(kind === 'locked' ? 2 : 1);
234
+ }
235
+
236
+ if (!argv.includes('--publish')) {
237
+ console.log('bozza caricata; ripassa con --publish per mandarla in review');
238
+ return;
239
+ }
240
+
241
+ const pub = await fetch(`https://www.googleapis.com/chromewebstore/v1.1/items/${cfg.item_id}/publish`, {
242
+ method: 'POST',
243
+ headers: { Authorization: `Bearer ${token}`, 'x-goog-api-version': '2', 'Content-Length': '0' },
244
+ });
245
+ const pubRes = interpretPublish(await pub.json());
246
+ console.log(`publish: ${pubRes.ok ? 'OK' : 'FALLITO'} ${pubRes.status.join(',')} ${pubRes.detail.join(' | ')}`);
247
+ if (!pubRes.ok) process.exit(1);
248
+ }
249
+
250
+ if (import.meta.url === `file://${process.argv[1]}`) {
251
+ main().catch((e) => { console.error(String(e.message || e)); process.exit(1); });
252
+ }