venombrowser 4.1.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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * VenomBrowser Bridge Server — Kimi-style single-port daemon
3
+ *
4
+ * v4.1.0 — Stability rewrite:
5
+ * - Global error handlers (no crashes)
6
+ * - Protected WebSocket send (no crash on disconnect mid-command)
7
+ * - Proper keepalive with timeout detection
8
+ * - HTTP server error handling
9
+ * - Graceful degradation
10
+ */
11
+
12
+ require('dotenv').config();
13
+
14
+ const http = require('http');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const WebSocket = require('ws');
18
+ const { v4: uuidv4 } = require('uuid');
19
+
20
+ const PORT = parseInt(process.env.BRIDGE_PORT || '10086', 10);
21
+ const WS_PORT = parseInt(process.env.WS_PORT || String(PORT + 1), 10);
22
+ const WS_PING_INTERVAL = 15000;
23
+ const WS_PING_TIMEOUT = 10000;
24
+ const CMD_TIMEOUT = 30000;
25
+ const SCREENSHOT_DIR = path.join(__dirname, '..', '..', 'screenshots');
26
+
27
+ class VenomBrowserBridgeServer {
28
+ constructor() {
29
+ this.extensionWs = null;
30
+ this.pending = new Map();
31
+ this.sessions = new Map();
32
+ this.startTime = Date.now();
33
+ this.commandCount = 0;
34
+ this.lastCommand = null;
35
+ this.lastCommandAt = null;
36
+ this.extensionInfo = null;
37
+ this.currentTab = null;
38
+ this.pingTimer = null;
39
+ this.lastPong = Date.now();
40
+
41
+ this.startHTTP();
42
+ this.startWS();
43
+ this.startPingLoop();
44
+ }
45
+
46
+ // ─── Safe WebSocket send ─────────────────────────────────────────────────
47
+ safeSend(ws, data) {
48
+ try {
49
+ if (ws && ws.readyState === WebSocket.OPEN) {
50
+ ws.send(typeof data === 'string' ? data : JSON.stringify(data));
51
+ return true;
52
+ }
53
+ } catch (e) {
54
+ console.error('[Bridge] safeSend error:', e.message);
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // ─── HTTP Server ─────────────────────────────────────────────────────────
60
+ startHTTP() {
61
+ this.httpServer = http.createServer((req, res) => {
62
+ res.setHeader('Access-Control-Allow-Origin', '*');
63
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
64
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
65
+
66
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
67
+
68
+ const send = (status, data) => {
69
+ try {
70
+ res.writeHead(status, { 'Content-Type': 'application/json' });
71
+ res.end(JSON.stringify(data));
72
+ } catch (_) {}
73
+ };
74
+
75
+ if (req.method === 'GET' && req.url === '/health') {
76
+ return send(200, {
77
+ server: 'ok',
78
+ extension: this.isConnected() ? 'connected' : 'disconnected',
79
+ port: PORT
80
+ });
81
+ }
82
+
83
+ if (req.method === 'GET' && req.url === '/status') {
84
+ return send(200, {
85
+ success: true,
86
+ connected: this.isConnected(),
87
+ uptime_seconds: ((Date.now() - this.startTime) / 1000).toFixed(1),
88
+ commands_executed: this.commandCount,
89
+ pending_commands: this.pending.size,
90
+ active_sessions: this.sessions.size,
91
+ last_command: this.lastCommand,
92
+ last_command_at: this.lastCommandAt,
93
+ extension: this.extensionInfo
94
+ });
95
+ }
96
+
97
+ if (req.method === 'POST' && req.url === '/command') {
98
+ let body = '';
99
+ req.on('data', chunk => { body += chunk; });
100
+ req.on('end', async () => {
101
+ let request;
102
+ try {
103
+ request = JSON.parse(body);
104
+ } catch (e) {
105
+ return send(400, { success: false, error: 'Invalid JSON' });
106
+ }
107
+ try {
108
+ const result = await this.handleCommand(request);
109
+ send(200, { success: true, ...result });
110
+ } catch (e) {
111
+ const code = e.message.includes('not connected') ? 503
112
+ : e.message.includes('timed out') ? 408
113
+ : 500;
114
+ send(code, { success: false, error: e.message });
115
+ }
116
+ });
117
+ return;
118
+ }
119
+
120
+ send(404, { success: false, error: 'Not found. Use POST /command' });
121
+ });
122
+
123
+ this.httpServer.on('error', (e) => {
124
+ console.error('[Bridge] HTTP server error:', e.message);
125
+ if (e.code === 'EADDRINUSE') {
126
+ console.error(`[Bridge] Port ${PORT} is already in use. Kill the other process or change BRIDGE_PORT in .env`);
127
+ process.exit(1);
128
+ }
129
+ });
130
+
131
+ this.httpServer.listen(PORT, '127.0.0.1', () => {
132
+ console.log(`[Bridge] HTTP on http://127.0.0.1:${PORT}`);
133
+ });
134
+ }
135
+
136
+ // ─── Command handler ─────────────────────────────────────────────────────
137
+ async handleCommand(request) {
138
+ const { action, args = {}, session = 'default', tabId } = request;
139
+
140
+ if (!action) throw new Error('action is required');
141
+
142
+ if (!this.sessions.has(session)) {
143
+ this.sessions.set(session, { tabs: new Set(), startTime: Date.now(), commandCount: 0 });
144
+ }
145
+ this.sessions.get(session).commandCount++;
146
+
147
+ if (action === 'health') return { service: 'venombrowser-bridge' };
148
+
149
+ if (action === 'status') {
150
+ return {
151
+ connected: this.isConnected(),
152
+ uptime_seconds: ((Date.now() - this.startTime) / 1000).toFixed(1),
153
+ commands_executed: this.commandCount,
154
+ pending_commands: this.pending.size,
155
+ active_sessions: this.sessions.size
156
+ };
157
+ }
158
+
159
+ if (!this.isConnected()) throw new Error('Extension not connected');
160
+
161
+ return this.executeAction(action, args, session, tabId);
162
+ }
163
+
164
+ // ─── Action router ───────────────────────────────────────────────────────
165
+ async executeAction(action, args, session, tabId) {
166
+ const send = (cmd, params = {}, tid = null, timeout = CMD_TIMEOUT) =>
167
+ this.sendToExtension(cmd, params, tid, timeout);
168
+
169
+ switch (action) {
170
+ case 'navigate': {
171
+ const { url, newTab = false } = args;
172
+ if (!url) throw new Error('url is required');
173
+ if (newTab) {
174
+ const r = await send('NEW_TAB', { url, active: true });
175
+ if (r.result?.tabId) this.sessions.get(session)?.tabs.add(r.result.tabId);
176
+ return { url: r.result?.url ?? url, tabId: r.result?.tabId };
177
+ }
178
+ const r = await send('NAVIGATE', { url, waitForLoad: true }, tabId);
179
+ if (r.result?.tabId) {
180
+ this.currentTab = r.result.tabId;
181
+ this.sessions.get(session)?.tabs.add(r.result.tabId);
182
+ }
183
+ return { url: r.result?.url ?? url, tabId: r.result?.tabId, title: r.result?.title };
184
+ }
185
+
186
+ case 'find_tab': {
187
+ const { url, active = false } = args;
188
+ if (active) {
189
+ const r = await send('GET_ACTIVE_TAB');
190
+ return { success: true, url: r.result?.url, tabId: r.result?.tabId, borrowed: true };
191
+ }
192
+ const r = await send('LIST_TABS');
193
+ const match = (r.result || []).find(t => t.url?.includes(url));
194
+ if (match) { this.currentTab = match.id; return { success: true, url: match.url, tabId: match.id }; }
195
+ throw new Error(`No tab matching ${url}`);
196
+ }
197
+
198
+ case 'list_tabs': {
199
+ const r = await send('LIST_TABS');
200
+ return { tabs: (r.result || []).map(t => ({ tabId: t.id, url: t.url, title: t.title, active: t.active })) };
201
+ }
202
+
203
+ case 'close_tab': {
204
+ const tid = tabId || this.currentTab;
205
+ if (!tid) throw new Error('No tab to close');
206
+ await send('CLOSE_TAB', {}, tid);
207
+ this.sessions.get(session)?.tabs.delete(tid);
208
+ if (this.currentTab === tid) this.currentTab = null;
209
+ return { closed: true };
210
+ }
211
+
212
+ case 'close_session': {
213
+ const tabs = this.sessions.get(session)?.tabs || new Set();
214
+ let closed = 0;
215
+ for (const t of tabs) { try { await send('CLOSE_TAB', {}, t); closed++; } catch (_) {} }
216
+ tabs.clear();
217
+ this.sessions.delete(session);
218
+ return { closed };
219
+ }
220
+
221
+ case 'snapshot': {
222
+ const r = await send('SNAPSHOT', {}, tabId || this.currentTab);
223
+ const res = r.result || {};
224
+ return { url: res.url, title: res.title, refCount: res.refCount, elements: res.elements };
225
+ }
226
+
227
+ case 'click': {
228
+ const r = await send('CLICK', { selector: args.selector }, tabId || this.currentTab);
229
+ return { success: true, tag: r.result?.tag, text: r.result?.text };
230
+ }
231
+
232
+ case 'fill': {
233
+ const r = await send('TYPE', { selector: args.selector, text: args.value, clear: true }, tabId || this.currentTab);
234
+ return { success: true, typed: r.result?.typed, length: r.result?.length };
235
+ }
236
+
237
+ case 'type': {
238
+ const r = await send('TYPE', { selector: args.selector, text: args.text, clear: args.clear, pressEnter: args.pressEnter }, tabId || this.currentTab);
239
+ return { success: true, typed: r.result?.typed, length: r.result?.length };
240
+ }
241
+
242
+ case 'submit_form': {
243
+ const r = await send('SUBMIT_FORM', { selector: args.selector }, tabId || this.currentTab);
244
+ return { success: true, submitted: r.result?.submitted, action: r.result?.action, method: r.result?.method };
245
+ }
246
+
247
+ case 'evaluate': {
248
+ const r = await send('EVALUATE', { code: args.code }, tabId || this.currentTab, 30000);
249
+ return { type: r.result?.type, value: r.result?.result };
250
+ }
251
+
252
+ case 'cdp': {
253
+ const r = await send('CDP', { method: args.method, params: args.params }, tabId || this.currentTab, 30000);
254
+ return r.result;
255
+ }
256
+
257
+ case 'screenshot': {
258
+ const r = await send('SCREENSHOT', { format: args.format, quality: args.quality }, tabId || this.currentTab);
259
+ // Save base64 to disk, return file path
260
+ if (r.result?.base64) {
261
+ if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
262
+ const filename = `screenshot-${Date.now()}.${r.result.format || 'png'}`;
263
+ const filepath = path.join(SCREENSHOT_DIR, filename);
264
+ fs.writeFileSync(filepath, Buffer.from(r.result.base64, 'base64'));
265
+ return { format: r.result.format, path: filepath, filename, sizeBytes: r.result.sizeBytes, mimeType: r.result.mimeType };
266
+ }
267
+ return { format: r.result?.format, path: r.result?.path, sizeBytes: r.result?.sizeBytes, mimeType: r.result?.mimeType };
268
+ }
269
+
270
+ case 'network': {
271
+ const r = await send('NETWORK', { cmd: args.cmd, filter: args.filter, requestId: args.requestId }, tabId || this.currentTab);
272
+ return r.result;
273
+ }
274
+
275
+ case 'upload': {
276
+ const r = await send('UPLOAD', { selector: args.selector, files: args.files }, tabId || this.currentTab);
277
+ return { success: true, fileCount: r.result?.fileCount };
278
+ }
279
+
280
+ case 'save_as_pdf': {
281
+ const r = await send('SAVE_AS_PDF', args, tabId || this.currentTab, 60000);
282
+ if (r.result?.base64) {
283
+ if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
284
+ const filename = `page-${Date.now()}.pdf`;
285
+ const filepath = path.join(SCREENSHOT_DIR, filename);
286
+ fs.writeFileSync(filepath, Buffer.from(r.result.base64, 'base64'));
287
+ return { path: filepath, sizeBytes: r.result.sizeBytes, mimeType: r.result.mimeType, pageTitle: r.result.pageTitle };
288
+ }
289
+ return { path: r.result?.path, sizeBytes: r.result?.sizeBytes, mimeType: r.result?.mimeType, pageTitle: r.result?.pageTitle };
290
+ }
291
+
292
+ case 'scroll': {
293
+ const r = await send('SCROLL', { direction: args.direction || 'down', amount: args.amount || 500 }, tabId || this.currentTab);
294
+ return r.result;
295
+ }
296
+
297
+ default:
298
+ throw new Error(`Unknown action: ${action}`);
299
+ }
300
+ }
301
+
302
+ // ─── WebSocket server ────────────────────────────────────────────────────
303
+ startWS() {
304
+ this.wss = new WebSocket.Server({ port: WS_PORT }, () => {
305
+ console.log(`[Bridge] WebSocket on ws://localhost:${WS_PORT}`);
306
+ });
307
+
308
+ this.wss.on('error', (e) => {
309
+ console.error('[Bridge] WS server error:', e.message);
310
+ if (e.code === 'EADDRINUSE') {
311
+ console.error(`[Bridge] WS port ${WS_PORT} in use. Change WS_PORT in .env`);
312
+ }
313
+ });
314
+
315
+ this.wss.on('connection', (ws, req) => {
316
+ const addr = req.socket.remoteAddress || 'unknown';
317
+ console.log(`[Bridge] Extension connected from ${addr}`);
318
+
319
+ // Replace previous connection
320
+ if (this.extensionWs && this.extensionWs.readyState === WebSocket.OPEN) {
321
+ console.log('[Bridge] Replacing previous extension');
322
+ try { this.extensionWs.close(1000); } catch (_) {}
323
+ }
324
+
325
+ this.extensionWs = ws;
326
+ this.extensionInfo = { connectedAt: Date.now(), address: addr };
327
+ this.lastPong = Date.now();
328
+
329
+ ws.on('message', (data) => {
330
+ try {
331
+ const msg = JSON.parse(data.toString());
332
+ this.onExtMessage(ws, msg);
333
+ } catch (_) {}
334
+ });
335
+
336
+ ws.on('close', (code, reason) => {
337
+ console.log(`[Bridge] Extension disconnected (code=${code})`);
338
+ if (this.extensionWs === ws) {
339
+ this.extensionWs = null;
340
+ this.extensionInfo = null;
341
+ this.rejectAll('Extension disconnected');
342
+ }
343
+ });
344
+
345
+ ws.on('error', (err) => {
346
+ console.error('[Bridge] Extension socket error:', err.message);
347
+ });
348
+
349
+ // Welcome
350
+ this.safeSend(ws, { type: 'welcome', daemon: 'venombrowser-bridge', version: '4.1.0' });
351
+ });
352
+ }
353
+
354
+ onExtMessage(ws, msg) {
355
+ // Keepalive
356
+ if (msg.type === 'ping') {
357
+ this.lastPong = Date.now();
358
+ this.safeSend(ws, { type: 'pong', timestamp: Date.now() });
359
+ return;
360
+ }
361
+ if (msg.type === 'pong') {
362
+ this.lastPong = Date.now();
363
+ return;
364
+ }
365
+ if (msg.type === 'hello') {
366
+ console.log(`[Bridge] Extension handshake: ${msg.client} v${msg.version}`);
367
+ return;
368
+ }
369
+
370
+ // Command response
371
+ if (msg.id && this.pending.has(msg.id)) {
372
+ const entry = this.pending.get(msg.id);
373
+ clearTimeout(entry.timer);
374
+ this.pending.delete(msg.id);
375
+ this.commandCount++;
376
+ this.lastCommand = msg.id;
377
+ this.lastCommandAt = new Date().toISOString();
378
+ if (msg.status === 'success') {
379
+ entry.resolve(msg);
380
+ } else {
381
+ entry.reject(new Error(msg.error || 'Command failed'));
382
+ }
383
+ }
384
+ }
385
+
386
+ // ─── Keepalive ping loop ─────────────────────────────────────────────────
387
+ startPingLoop() {
388
+ this.pingTimer = setInterval(() => {
389
+ if (!this.extensionWs || this.extensionWs.readyState !== WebSocket.OPEN) return;
390
+
391
+ // Check if extension is dead (no pong in timeout period)
392
+ if (Date.now() - this.lastPong > WS_PING_TIMEOUT + WS_PING_INTERVAL) {
393
+ console.warn('[Bridge] Extension timed out — closing');
394
+ try { this.extensionWs.close(4001, 'Ping timeout'); } catch (_) {}
395
+ return;
396
+ }
397
+
398
+ this.safeSend(this.extensionWs, { type: 'ping', timestamp: Date.now() });
399
+ }, WS_PING_INTERVAL);
400
+ }
401
+
402
+ // ─── Send command to extension ───────────────────────────────────────────
403
+ sendToExtension(command, params = {}, tabId = null, timeoutMs = CMD_TIMEOUT) {
404
+ if (!this.isConnected()) {
405
+ return Promise.reject(new Error('Extension not connected'));
406
+ }
407
+
408
+ const id = uuidv4();
409
+ const normalizedTabId = tabId == null ? null : Number(tabId) || null;
410
+ const msg = { id, command, params, tabId: normalizedTabId, timeout: timeoutMs };
411
+
412
+ return new Promise((resolve, reject) => {
413
+ const timer = setTimeout(() => {
414
+ this.pending.delete(id);
415
+ reject(new Error(`Command ${command} timed out after ${timeoutMs}ms`));
416
+ }, timeoutMs);
417
+
418
+ this.pending.set(id, { resolve, reject, timer });
419
+
420
+ if (!this.safeSend(this.extensionWs, msg)) {
421
+ clearTimeout(timer);
422
+ this.pending.delete(id);
423
+ reject(new Error('Failed to send command to extension'));
424
+ }
425
+ });
426
+ }
427
+
428
+ rejectAll(reason) {
429
+ for (const { reject, timer } of this.pending.values()) {
430
+ clearTimeout(timer);
431
+ reject(new Error(reason));
432
+ }
433
+ this.pending.clear();
434
+ }
435
+
436
+ isConnected() {
437
+ return this.extensionWs?.readyState === WebSocket.OPEN;
438
+ }
439
+
440
+ shutdown() {
441
+ console.log('\n[Bridge] Shutting down...');
442
+ clearInterval(this.pingTimer);
443
+ this.rejectAll('Server shutting down');
444
+ try { this.extensionWs?.close(1000); } catch (_) {}
445
+ try { this.wss?.close(); } catch (_) {}
446
+ try {
447
+ this.httpServer?.close(() => {
448
+ console.log('[Bridge] Stopped.');
449
+ process.exit(0);
450
+ });
451
+ } catch (_) {}
452
+ setTimeout(() => process.exit(0), 2000);
453
+ }
454
+ }
455
+
456
+ // ─── Global error handlers (prevent crashes) ─────────────────────────────
457
+ process.on('uncaughtException', (e) => {
458
+ console.error('[Bridge] UNCAUGHT EXCEPTION:', e.message);
459
+ // Don't exit — keep running
460
+ });
461
+ process.on('unhandledRejection', (reason) => {
462
+ console.error('[Bridge] UNHANDLED REJECTION:', reason);
463
+ // Don't exit — keep running
464
+ });
465
+
466
+ // ─── Start ────────────────────────────────────────────────────────────────
467
+ console.log('🚀 Starting VenomBrowser Bridge Server v4.1.0...');
468
+ const server = new VenomBrowserBridgeServer();
469
+ console.log(`📡 WebSocket : ws://localhost:${WS_PORT}`);
470
+ console.log(`🌐 HTTP API : http://127.0.0.1:${PORT}/command`);
471
+ console.log(`📋 Status : http://127.0.0.1:${PORT}/status`);
472
+ console.log('\nLoad extension from: ./extension/');
473
+ console.log('Press Ctrl+C to stop.\n');
474
+
475
+ process.on('SIGINT', () => server.shutdown());
476
+ process.on('SIGTERM', () => server.shutdown());
package/src/connect.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * VenomBrowser Connect — Auto-register MCP server with known agents
3
+ *
4
+ * Detects existing agent config files (Claude Desktop, OpenCode, Cursor)
5
+ * and merges a "venombrowser" MCP server entry into each without
6
+ * clobbering any existing entries.
7
+ *
8
+ * Uses jsonc-parser for reading/writing JSONC files, preserving comments
9
+ * and formatting in the user's config.
10
+ *
11
+ * Usage:
12
+ * const { connect } = require('./connect');
13
+ * await connect(); // auto-detect and register
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const jsonc = require('jsonc-parser');
20
+
21
+ /**
22
+ * Build the MCP server entry object for Claude Desktop / Cursor format.
23
+ */
24
+ function buildMcpEntry() {
25
+ const mcpPath = path.resolve(__dirname, '..', 'bin', 'venombrowser-mcp.js');
26
+ return {
27
+ command: 'node',
28
+ args: [mcpPath.replace(/\\/g, '/')],
29
+ env: { BRIDGE_PORT: '10086' }
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Build the MCP server entry object for OpenCode format.
35
+ * OpenCode uses: { type: "local", command: [...], environment: {...} }
36
+ */
37
+ function buildOpenCodeMcpEntry() {
38
+ const mcpPath = path.resolve(__dirname, '..', 'bin', 'venombrowser-mcp.js');
39
+ return {
40
+ type: 'local',
41
+ command: ['node', mcpPath.replace(/\\/g, '/')],
42
+ environment: { BRIDGE_PORT: '10086' }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Get the list of known agent config file paths to check.
48
+ * Returns an array of { name, path, format } objects.
49
+ * format is "mcpServers" for Claude Desktop/Cursor, "mcp" for OpenCode.
50
+ */
51
+ function getAgentConfigPaths() {
52
+ const home = os.homedir();
53
+ const configs = [];
54
+
55
+ // Claude Desktop
56
+ if (process.platform === 'win32') {
57
+ const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
58
+ configs.push({
59
+ name: 'Claude Desktop',
60
+ path: path.join(appdata, 'Claude', 'claude_desktop_config.json'),
61
+ format: 'mcpServers'
62
+ });
63
+ } else {
64
+ configs.push({
65
+ name: 'Claude Desktop',
66
+ path: path.join(home, '.config', 'claude', 'claude_desktop_config.json'),
67
+ format: 'mcpServers'
68
+ });
69
+ }
70
+
71
+ // Cursor
72
+ configs.push({
73
+ name: 'Cursor',
74
+ path: path.join(home, '.cursor', 'mcp.json'),
75
+ format: 'mcpServers'
76
+ });
77
+
78
+ // OpenCode
79
+ configs.push({
80
+ name: 'OpenCode',
81
+ path: path.join(home, '.config', 'opencode', 'opencode.jsonc'),
82
+ format: 'mcp'
83
+ });
84
+
85
+ return configs;
86
+ }
87
+
88
+ /**
89
+ * Merge the venombrowser entry into a config file's mcpServers (Claude Desktop / Cursor).
90
+ * Uses jsonc-parser to preserve comments and formatting.
91
+ *
92
+ * @param {string} filePath - Absolute path to the config file
93
+ * @returns {{ merged: boolean, existed: boolean }} result
94
+ */
95
+ function mergeIntoConfig(filePath) {
96
+ let content;
97
+ try {
98
+ content = fs.readFileSync(filePath, 'utf8');
99
+ } catch {
100
+ return { merged: false, existed: false };
101
+ }
102
+
103
+ let config;
104
+ try {
105
+ config = jsonc.parse(content);
106
+ } catch {
107
+ console.warn(` ⚠️ Skipping ${filePath} — invalid JSON`);
108
+ return { merged: false, existed: true };
109
+ }
110
+
111
+ // Ensure mcpServers key exists
112
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
113
+ config.mcpServers = {};
114
+ }
115
+
116
+ // Check if already registered
117
+ if (config.mcpServers['venombrowser']) {
118
+ return { merged: false, existed: true, alreadyRegistered: true };
119
+ }
120
+
121
+ // Merge in the new entry — does NOT clobber existing entries
122
+ config.mcpServers['venombrowser'] = buildMcpEntry();
123
+
124
+ // Use jsonc-parser to write back, preserving comments and formatting
125
+ const edits = jsonc.modify(content, ['mcpServers', 'venombrowser'], buildMcpEntry(), { formattingOptions: { insertSpaces: true, tabSize: 2 } });
126
+ const updated = jsonc.applyEdits(content, edits);
127
+ fs.writeFileSync(filePath, updated, 'utf8');
128
+ return { merged: true, existed: true };
129
+ }
130
+
131
+ /**
132
+ * Merge the venombrowser entry into OpenCode's config file.
133
+ * OpenCode uses "mcp" key (not "mcpServers") and a different entry format.
134
+ * Preserves "$schema" and any existing "mcp" entries.
135
+ *
136
+ * @param {string} filePath - Absolute path to the config file
137
+ * @returns {{ merged: boolean, existed: boolean }} result
138
+ */
139
+ function mergeIntoOpenCodeConfig(filePath) {
140
+ let content;
141
+ try {
142
+ content = fs.readFileSync(filePath, 'utf8');
143
+ } catch {
144
+ return { merged: false, existed: false };
145
+ }
146
+
147
+ let config;
148
+ try {
149
+ config = jsonc.parse(content);
150
+ } catch {
151
+ console.warn(` ⚠️ Skipping ${filePath} — invalid JSON`);
152
+ return { merged: false, existed: true };
153
+ }
154
+
155
+ // Ensure mcp key exists
156
+ if (!config.mcp || typeof config.mcp !== 'object') {
157
+ config.mcp = {};
158
+ }
159
+
160
+ // Check if already registered
161
+ if (config.mcp['venombrowser']) {
162
+ return { merged: false, existed: true, alreadyRegistered: true };
163
+ }
164
+
165
+ // Use jsonc-parser to write back, preserving comments and formatting
166
+ const entry = buildOpenCodeMcpEntry();
167
+ const edits = jsonc.modify(content, ['mcp', 'venombrowser'], entry, { formattingOptions: { insertSpaces: true, tabSize: 2 } });
168
+ const updated = jsonc.applyEdits(content, edits);
169
+ fs.writeFileSync(filePath, updated, 'utf8');
170
+ return { merged: true, existed: true };
171
+ }
172
+
173
+ /**
174
+ * Run the auto-registration across all known agent configs.
175
+ * @returns {string[]} List of agent names that were successfully registered.
176
+ */
177
+ function connect() {
178
+ const entry = buildMcpEntry();
179
+ console.log('🔌 VenomBrowser Connect — Auto-registering MCP server with agents');
180
+ console.log('═'.repeat(55));
181
+ console.log(` MCP entry: node ${entry.args[0]}`);
182
+ console.log();
183
+
184
+ const configs = getAgentConfigPaths();
185
+ const registered = [];
186
+
187
+ for (const { name, path: configPath, format } of configs) {
188
+ if (!fs.existsSync(configPath)) {
189
+ console.log(` ⏭️ ${name}: config not found, skipping`);
190
+ continue;
191
+ }
192
+
193
+ const result = format === 'mcp'
194
+ ? mergeIntoOpenCodeConfig(configPath)
195
+ : mergeIntoConfig(configPath);
196
+
197
+ if (result.alreadyRegistered) {
198
+ console.log(` ✅ ${name}: already registered`);
199
+ registered.push(name);
200
+ } else if (result.merged) {
201
+ console.log(` ✅ ${name}: registered successfully`);
202
+ registered.push(name);
203
+ } else if (result.existed) {
204
+ console.log(` ⚠️ ${name}: skipped (could not merge)`);
205
+ }
206
+ }
207
+
208
+ console.log();
209
+ if (registered.length > 0) {
210
+ console.log(`🎉 Registered with: ${registered.join(', ')}`);
211
+ } else {
212
+ console.log('ℹ️ No agent configs found. You can manually add VenomBrowser to your agent\'s MCP config:');
213
+ console.log(JSON.stringify({ 'venombrowser': entry }, null, 2));
214
+ }
215
+ console.log();
216
+
217
+ return registered;
218
+ }
219
+
220
+ module.exports = { connect, buildMcpEntry, buildOpenCodeMcpEntry, mergeIntoConfig, mergeIntoOpenCodeConfig, getAgentConfigPaths };