@different-ai/opencode-browser 1.0.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,456 @@
1
+ // OpenCode Browser Automation - Background Service Worker
2
+ // Native Messaging Host: com.opencode.browser_automation
3
+
4
+ const NATIVE_HOST_NAME = "com.opencode.browser_automation";
5
+
6
+ let nativePort = null;
7
+ let isConnected = false;
8
+
9
+ // ============================================================================
10
+ // Native Messaging Connection
11
+ // ============================================================================
12
+
13
+ async function connectToNativeHost() {
14
+ if (nativePort) {
15
+ return true;
16
+ }
17
+
18
+ try {
19
+ nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME);
20
+
21
+ nativePort.onMessage.addListener(handleNativeMessage);
22
+
23
+ nativePort.onDisconnect.addListener(() => {
24
+ const error = chrome.runtime.lastError?.message;
25
+ console.log("[OpenCode] Native host disconnected:", error);
26
+ nativePort = null;
27
+ isConnected = false;
28
+ });
29
+
30
+ // Ping to verify connection
31
+ const connected = await new Promise((resolve) => {
32
+ const timeout = setTimeout(() => resolve(false), 5000);
33
+
34
+ const pingHandler = (msg) => {
35
+ if (msg.type === "pong") {
36
+ clearTimeout(timeout);
37
+ nativePort.onMessage.removeListener(pingHandler);
38
+ resolve(true);
39
+ }
40
+ };
41
+
42
+ nativePort.onMessage.addListener(pingHandler);
43
+ nativePort.postMessage({ type: "ping" });
44
+ });
45
+
46
+ if (connected) {
47
+ isConnected = true;
48
+ console.log("[OpenCode] Connected to native host");
49
+ return true;
50
+ } else {
51
+ nativePort.disconnect();
52
+ nativePort = null;
53
+ return false;
54
+ }
55
+ } catch (error) {
56
+ console.error("[OpenCode] Failed to connect:", error);
57
+ nativePort = null;
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function disconnectNativeHost() {
63
+ if (nativePort) {
64
+ nativePort.disconnect();
65
+ nativePort = null;
66
+ isConnected = false;
67
+ }
68
+ }
69
+
70
+ // ============================================================================
71
+ // Message Handling from Native Host
72
+ // ============================================================================
73
+
74
+ async function handleNativeMessage(message) {
75
+ console.log("[OpenCode] Received from native:", message.type);
76
+
77
+ switch (message.type) {
78
+ case "tool_request":
79
+ await handleToolRequest(message);
80
+ break;
81
+ case "ping":
82
+ sendToNative({ type: "pong" });
83
+ break;
84
+ case "get_status":
85
+ sendToNative({
86
+ type: "status_response",
87
+ connected: isConnected,
88
+ version: chrome.runtime.getManifest().version
89
+ });
90
+ break;
91
+ }
92
+ }
93
+
94
+ function sendToNative(message) {
95
+ if (nativePort) {
96
+ nativePort.postMessage(message);
97
+ } else {
98
+ console.error("[OpenCode] Cannot send - not connected");
99
+ }
100
+ }
101
+
102
+ // ============================================================================
103
+ // Tool Execution
104
+ // ============================================================================
105
+
106
+ async function handleToolRequest(request) {
107
+ const { id, tool, args } = request;
108
+
109
+ try {
110
+ const result = await executeTool(tool, args || {});
111
+ sendToNative({
112
+ type: "tool_response",
113
+ id,
114
+ result: { content: result }
115
+ });
116
+ } catch (error) {
117
+ sendToNative({
118
+ type: "tool_response",
119
+ id,
120
+ error: { content: error.message || String(error) }
121
+ });
122
+ }
123
+ }
124
+
125
+ async function executeTool(toolName, args) {
126
+ switch (toolName) {
127
+ case "navigate":
128
+ return await toolNavigate(args);
129
+ case "click":
130
+ return await toolClick(args);
131
+ case "type":
132
+ return await toolType(args);
133
+ case "screenshot":
134
+ return await toolScreenshot(args);
135
+ case "snapshot":
136
+ return await toolSnapshot(args);
137
+ case "get_tabs":
138
+ return await toolGetTabs(args);
139
+ case "execute_script":
140
+ return await toolExecuteScript(args);
141
+ case "scroll":
142
+ return await toolScroll(args);
143
+ case "wait":
144
+ return await toolWait(args);
145
+ default:
146
+ throw new Error(`Unknown tool: ${toolName}`);
147
+ }
148
+ }
149
+
150
+ // ============================================================================
151
+ // Tool Implementations
152
+ // ============================================================================
153
+
154
+ async function getActiveTab() {
155
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
156
+ if (!tab?.id) throw new Error("No active tab found");
157
+ return tab;
158
+ }
159
+
160
+ async function getTabById(tabId) {
161
+ if (tabId) {
162
+ return await chrome.tabs.get(tabId);
163
+ }
164
+ return await getActiveTab();
165
+ }
166
+
167
+ async function toolNavigate({ url, tabId }) {
168
+ if (!url) throw new Error("URL is required");
169
+
170
+ const tab = await getTabById(tabId);
171
+ await chrome.tabs.update(tab.id, { url });
172
+
173
+ // Wait for page to load
174
+ await new Promise((resolve) => {
175
+ const listener = (updatedTabId, info) => {
176
+ if (updatedTabId === tab.id && info.status === "complete") {
177
+ chrome.tabs.onUpdated.removeListener(listener);
178
+ resolve();
179
+ }
180
+ };
181
+ chrome.tabs.onUpdated.addListener(listener);
182
+ // Timeout after 30 seconds
183
+ setTimeout(() => {
184
+ chrome.tabs.onUpdated.removeListener(listener);
185
+ resolve();
186
+ }, 30000);
187
+ });
188
+
189
+ return `Navigated to ${url}`;
190
+ }
191
+
192
+ async function toolClick({ selector, tabId }) {
193
+ if (!selector) throw new Error("Selector is required");
194
+
195
+ const tab = await getTabById(tabId);
196
+
197
+ const result = await chrome.scripting.executeScript({
198
+ target: { tabId: tab.id },
199
+ func: (sel) => {
200
+ const element = document.querySelector(sel);
201
+ if (!element) return { success: false, error: `Element not found: ${sel}` };
202
+ element.click();
203
+ return { success: true };
204
+ },
205
+ args: [selector]
206
+ });
207
+
208
+ if (!result[0]?.result?.success) {
209
+ throw new Error(result[0]?.result?.error || "Click failed");
210
+ }
211
+
212
+ return `Clicked ${selector}`;
213
+ }
214
+
215
+ async function toolType({ selector, text, tabId, clear = false }) {
216
+ if (!selector) throw new Error("Selector is required");
217
+ if (text === undefined) throw new Error("Text is required");
218
+
219
+ const tab = await getTabById(tabId);
220
+
221
+ const result = await chrome.scripting.executeScript({
222
+ target: { tabId: tab.id },
223
+ func: (sel, txt, shouldClear) => {
224
+ const element = document.querySelector(sel);
225
+ if (!element) return { success: false, error: `Element not found: ${sel}` };
226
+
227
+ element.focus();
228
+ if (shouldClear) {
229
+ element.value = "";
230
+ }
231
+
232
+ // For input/textarea, set value directly
233
+ if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
234
+ element.value = element.value + txt;
235
+ element.dispatchEvent(new Event("input", { bubbles: true }));
236
+ element.dispatchEvent(new Event("change", { bubbles: true }));
237
+ } else if (element.isContentEditable) {
238
+ document.execCommand("insertText", false, txt);
239
+ }
240
+
241
+ return { success: true };
242
+ },
243
+ args: [selector, text, clear]
244
+ });
245
+
246
+ if (!result[0]?.result?.success) {
247
+ throw new Error(result[0]?.result?.error || "Type failed");
248
+ }
249
+
250
+ return `Typed "${text}" into ${selector}`;
251
+ }
252
+
253
+ async function toolScreenshot({ tabId, fullPage = false }) {
254
+ const tab = await getTabById(tabId);
255
+
256
+ // Capture visible area
257
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
258
+ format: "png"
259
+ });
260
+
261
+ return dataUrl;
262
+ }
263
+
264
+ async function toolSnapshot({ tabId }) {
265
+ const tab = await getTabById(tabId);
266
+
267
+ const result = await chrome.scripting.executeScript({
268
+ target: { tabId: tab.id },
269
+ func: () => {
270
+ // Build accessibility tree snapshot
271
+ function getAccessibleName(element) {
272
+ return element.getAttribute("aria-label") ||
273
+ element.getAttribute("alt") ||
274
+ element.getAttribute("title") ||
275
+ element.getAttribute("placeholder") ||
276
+ element.innerText?.slice(0, 100) ||
277
+ "";
278
+ }
279
+
280
+ function getRole(element) {
281
+ return element.getAttribute("role") ||
282
+ element.tagName.toLowerCase();
283
+ }
284
+
285
+ function buildSnapshot(element, depth = 0, uid = 0) {
286
+ if (depth > 10) return { nodes: [], nextUid: uid };
287
+
288
+ const nodes = [];
289
+ const style = window.getComputedStyle(element);
290
+
291
+ // Skip hidden elements
292
+ if (style.display === "none" || style.visibility === "hidden") {
293
+ return { nodes: [], nextUid: uid };
294
+ }
295
+
296
+ const isInteractive =
297
+ element.tagName === "A" ||
298
+ element.tagName === "BUTTON" ||
299
+ element.tagName === "INPUT" ||
300
+ element.tagName === "TEXTAREA" ||
301
+ element.tagName === "SELECT" ||
302
+ element.getAttribute("onclick") ||
303
+ element.getAttribute("role") === "button" ||
304
+ element.isContentEditable;
305
+
306
+ const rect = element.getBoundingClientRect();
307
+ const isVisible = rect.width > 0 && rect.height > 0;
308
+
309
+ if (isVisible && (isInteractive || element.innerText?.trim())) {
310
+ const node = {
311
+ uid: `e${uid}`,
312
+ role: getRole(element),
313
+ name: getAccessibleName(element).slice(0, 200),
314
+ tag: element.tagName.toLowerCase()
315
+ };
316
+
317
+ if (element.tagName === "A" && element.href) {
318
+ node.href = element.href;
319
+ }
320
+ if (element.tagName === "INPUT") {
321
+ node.type = element.type;
322
+ node.value = element.value;
323
+ }
324
+
325
+ // Generate a selector
326
+ if (element.id) {
327
+ node.selector = `#${element.id}`;
328
+ } else if (element.className && typeof element.className === "string") {
329
+ const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
330
+ if (classes) node.selector = `${element.tagName.toLowerCase()}.${classes}`;
331
+ }
332
+
333
+ nodes.push(node);
334
+ uid++;
335
+ }
336
+
337
+ for (const child of element.children) {
338
+ const childResult = buildSnapshot(child, depth + 1, uid);
339
+ nodes.push(...childResult.nodes);
340
+ uid = childResult.nextUid;
341
+ }
342
+
343
+ return { nodes, nextUid: uid };
344
+ }
345
+
346
+ const { nodes } = buildSnapshot(document.body);
347
+
348
+ return {
349
+ url: window.location.href,
350
+ title: document.title,
351
+ nodes: nodes.slice(0, 500) // Limit to 500 nodes
352
+ };
353
+ }
354
+ });
355
+
356
+ return JSON.stringify(result[0]?.result, null, 2);
357
+ }
358
+
359
+ async function toolGetTabs() {
360
+ const tabs = await chrome.tabs.query({});
361
+ return JSON.stringify(tabs.map(t => ({
362
+ id: t.id,
363
+ url: t.url,
364
+ title: t.title,
365
+ active: t.active,
366
+ windowId: t.windowId
367
+ })), null, 2);
368
+ }
369
+
370
+ async function toolExecuteScript({ code, tabId }) {
371
+ if (!code) throw new Error("Code is required");
372
+
373
+ const tab = await getTabById(tabId);
374
+
375
+ const result = await chrome.scripting.executeScript({
376
+ target: { tabId: tab.id },
377
+ func: new Function(code)
378
+ });
379
+
380
+ return JSON.stringify(result[0]?.result);
381
+ }
382
+
383
+ async function toolScroll({ x = 0, y = 0, selector, tabId }) {
384
+ const tab = await getTabById(tabId);
385
+
386
+ // Ensure selector is null (not undefined) for proper serialization
387
+ const sel = selector || null;
388
+
389
+ await chrome.scripting.executeScript({
390
+ target: { tabId: tab.id },
391
+ func: (scrollX, scrollY, sel) => {
392
+ if (sel) {
393
+ const element = document.querySelector(sel);
394
+ if (element) {
395
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
396
+ return;
397
+ }
398
+ }
399
+ window.scrollBy(scrollX, scrollY);
400
+ },
401
+ args: [x, y, sel]
402
+ });
403
+
404
+ return `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}`;
405
+ }
406
+
407
+ async function toolWait({ ms = 1000 }) {
408
+ await new Promise(resolve => setTimeout(resolve, ms));
409
+ return `Waited ${ms}ms`;
410
+ }
411
+
412
+ // ============================================================================
413
+ // Extension Lifecycle
414
+ // ============================================================================
415
+
416
+ chrome.runtime.onInstalled.addListener(async () => {
417
+ console.log("[OpenCode] Extension installed");
418
+ await connectToNativeHost();
419
+ });
420
+
421
+ chrome.runtime.onStartup.addListener(async () => {
422
+ console.log("[OpenCode] Extension started");
423
+ await connectToNativeHost();
424
+ });
425
+
426
+ // Auto-reconnect on action click
427
+ chrome.action.onClicked.addListener(async () => {
428
+ if (!isConnected) {
429
+ const connected = await connectToNativeHost();
430
+ if (connected) {
431
+ chrome.notifications.create({
432
+ type: "basic",
433
+ iconUrl: "icons/icon128.png",
434
+ title: "OpenCode Browser",
435
+ message: "Connected to native host"
436
+ });
437
+ } else {
438
+ chrome.notifications.create({
439
+ type: "basic",
440
+ iconUrl: "icons/icon128.png",
441
+ title: "OpenCode Browser",
442
+ message: "Failed to connect. Is the native host installed?"
443
+ });
444
+ }
445
+ } else {
446
+ chrome.notifications.create({
447
+ type: "basic",
448
+ iconUrl: "icons/icon128.png",
449
+ title: "OpenCode Browser",
450
+ message: "Already connected"
451
+ });
452
+ }
453
+ });
454
+
455
+ // Try to connect on load
456
+ connectToNativeHost();
Binary file
Binary file
Binary file
@@ -0,0 +1,34 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "OpenCode Browser Automation",
4
+ "version": "1.0.0",
5
+ "description": "Browser automation for OpenCode via Native Messaging",
6
+ "permissions": [
7
+ "tabs",
8
+ "activeTab",
9
+ "scripting",
10
+ "nativeMessaging",
11
+ "storage",
12
+ "notifications"
13
+ ],
14
+ "host_permissions": [
15
+ "<all_urls>"
16
+ ],
17
+ "background": {
18
+ "service_worker": "background.js",
19
+ "type": "module"
20
+ },
21
+ "action": {
22
+ "default_title": "OpenCode Browser",
23
+ "default_icon": {
24
+ "16": "icons/icon16.png",
25
+ "48": "icons/icon48.png",
26
+ "128": "icons/icon128.png"
27
+ }
28
+ },
29
+ "icons": {
30
+ "16": "icons/icon16.png",
31
+ "48": "icons/icon48.png",
32
+ "128": "icons/icon128.png"
33
+ }
34
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@different-ai/opencode-browser",
3
+ "version": "1.0.0",
4
+ "description": "Browser automation for OpenCode via Chrome extension + Native Messaging. Inspired by Claude in Chrome.",
5
+ "type": "module",
6
+ "bin": {
7
+ "opencode-browser": "./bin/cli.js"
8
+ },
9
+ "main": "./src/server.js",
10
+ "scripts": {
11
+ "start": "node src/server.js",
12
+ "install-extension": "node bin/cli.js install"
13
+ },
14
+ "keywords": [
15
+ "opencode",
16
+ "browser",
17
+ "automation",
18
+ "chrome",
19
+ "mcp",
20
+ "claude"
21
+ ],
22
+ "author": "Benjamin Shafii",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/different-ai/opencode-browser.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/different-ai/opencode-browser/issues"
30
+ },
31
+ "homepage": "https://github.com/different-ai/opencode-browser#readme",
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.0.0"
34
+ }
35
+ }