@different-ai/opencode-browser 3.0.0 → 4.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.
package/bin/cli.js CHANGED
@@ -2,30 +2,48 @@
2
2
  /**
3
3
  * OpenCode Browser - CLI
4
4
  *
5
+ * Architecture (v4):
6
+ * OpenCode Plugin <-> Local Broker (unix socket) <-> Native Messaging Host <-> Chrome Extension
7
+ *
5
8
  * Commands:
6
- * install - Install Chrome extension
7
- * serve - Run MCP server (used by OpenCode)
8
- * status - Check connection status
9
+ * install - Install extension + native host
10
+ * uninstall - Remove native host registration
11
+ * status - Show installation status
9
12
  */
10
13
 
11
- import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, unlinkSync } from "fs";
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ writeFileSync,
18
+ readFileSync,
19
+ copyFileSync,
20
+ readdirSync,
21
+ unlinkSync,
22
+ chmodSync,
23
+ } from "fs";
12
24
  import { homedir, platform } from "os";
13
25
  import { join, dirname } from "path";
14
26
  import { fileURLToPath } from "url";
15
- import { execSync, spawn } from "child_process";
16
27
  import { createInterface } from "readline";
17
28
 
18
29
  const __filename = fileURLToPath(import.meta.url);
19
30
  const __dirname = dirname(__filename);
20
31
  const PACKAGE_ROOT = join(__dirname, "..");
21
32
 
33
+ const BASE_DIR = join(homedir(), ".opencode-browser");
34
+ const EXTENSION_DIR = join(BASE_DIR, "extension");
35
+ const BROKER_DST = join(BASE_DIR, "broker.cjs");
36
+ const NATIVE_HOST_DST = join(BASE_DIR, "native-host.cjs");
37
+ const CONFIG_DST = join(BASE_DIR, "config.json");
38
+
39
+ const NATIVE_HOST_NAME = "com.opencode.browser_automation";
40
+
22
41
  const COLORS = {
23
42
  reset: "\x1b[0m",
24
43
  bright: "\x1b[1m",
25
44
  red: "\x1b[31m",
26
45
  green: "\x1b[32m",
27
46
  yellow: "\x1b[33m",
28
- blue: "\x1b[34m",
29
47
  cyan: "\x1b[36m",
30
48
  };
31
49
 
@@ -61,9 +79,7 @@ const rl = createInterface({
61
79
 
62
80
  function ask(question) {
63
81
  return new Promise((resolve) => {
64
- rl.question(question, (answer) => {
65
- resolve(answer.trim());
66
- });
82
+ rl.question(question, (answer) => resolve(answer.trim()));
67
83
  });
68
84
  }
69
85
 
@@ -72,323 +88,306 @@ async function confirm(question) {
72
88
  return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
73
89
  }
74
90
 
91
+ function ensureDir(p) {
92
+ mkdirSync(p, { recursive: true });
93
+ }
94
+
95
+ function copyDirRecursive(srcDir, destDir) {
96
+ ensureDir(destDir);
97
+ const entries = readdirSync(srcDir, { recursive: true });
98
+ for (const entry of entries) {
99
+ const srcPath = join(srcDir, entry);
100
+ const destPath = join(destDir, entry);
101
+
102
+ try {
103
+ readdirSync(srcPath);
104
+ ensureDir(destPath);
105
+ } catch {
106
+ ensureDir(dirname(destPath));
107
+ copyFileSync(srcPath, destPath);
108
+ }
109
+ }
110
+ }
111
+
112
+ function getNativeHostDirs(osName) {
113
+ if (osName === "darwin") {
114
+ const base = join(homedir(), "Library", "Application Support");
115
+ return [
116
+ join(base, "Google", "Chrome", "NativeMessagingHosts"),
117
+ join(base, "Chromium", "NativeMessagingHosts"),
118
+ join(base, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts"),
119
+ ];
120
+ }
121
+
122
+ // linux
123
+ const base = join(homedir(), ".config");
124
+ return [
125
+ join(base, "google-chrome", "NativeMessagingHosts"),
126
+ join(base, "chromium", "NativeMessagingHosts"),
127
+ join(base, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts"),
128
+ ];
129
+ }
130
+
131
+ function nativeHostManifestPath(dir) {
132
+ return join(dir, `${NATIVE_HOST_NAME}.json`);
133
+ }
134
+
135
+ function writeNativeHostManifest(dir, extensionId) {
136
+ ensureDir(dir);
137
+
138
+ const manifest = {
139
+ name: NATIVE_HOST_NAME,
140
+ description: "OpenCode Browser native messaging host",
141
+ path: NATIVE_HOST_DST,
142
+ type: "stdio",
143
+ allowed_origins: [`chrome-extension://${extensionId}/`],
144
+ };
145
+
146
+ writeFileSync(nativeHostManifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
147
+ }
148
+
149
+ function loadConfig() {
150
+ try {
151
+ if (!existsSync(CONFIG_DST)) return null;
152
+ return JSON.parse(readFileSync(CONFIG_DST, "utf-8"));
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function saveConfig(config) {
159
+ ensureDir(BASE_DIR);
160
+ writeFileSync(CONFIG_DST, JSON.stringify(config, null, 2) + "\n");
161
+ }
162
+
75
163
  async function main() {
76
164
  const command = process.argv[2];
77
165
 
78
- if (command === "serve") {
79
- // Run MCP server - this is called by OpenCode
80
- await serve();
81
- } else if (command === "install") {
82
- await showHeader();
166
+ console.log(`
167
+ ${color("cyan", color("bright", "OpenCode Browser v4"))}
168
+ ${color("cyan", "Browser automation plugin (native messaging + per-tab ownership)")}
169
+ `);
170
+
171
+ if (command === "install") {
83
172
  await install();
84
- rl.close();
85
173
  } else if (command === "uninstall") {
86
- await showHeader();
87
174
  await uninstall();
88
- rl.close();
89
175
  } else if (command === "status") {
90
- await showHeader();
91
176
  await status();
92
- rl.close();
93
177
  } else {
94
- await showHeader();
95
178
  log(`
96
179
  ${color("bright", "Usage:")}
97
- npx @different-ai/opencode-browser install Install extension
98
- npx @different-ai/opencode-browser uninstall Remove installation
99
- npx @different-ai/opencode-browser status Check status
100
- npx @different-ai/opencode-browser serve Run MCP server (internal)
180
+ npx @different-ai/opencode-browser install
181
+ npx @different-ai/opencode-browser status
182
+ npx @different-ai/opencode-browser uninstall
101
183
 
102
184
  ${color("bright", "Quick Start:")}
103
185
  1. Run: npx @different-ai/opencode-browser install
104
- 2. Add to your opencode.json:
105
- ${color("cyan", `"mcp": { "browser": { "type": "local", "command": ["bunx", "@different-ai/opencode-browser", "serve"] } }`)}
106
- 3. Restart OpenCode
186
+ 2. Restart OpenCode
187
+ 3. Use: browser_navigate / browser_click / browser_snapshot
107
188
  `);
108
- rl.close();
109
189
  }
110
- }
111
-
112
- async function showHeader() {
113
- console.log(`
114
- ${color("cyan", color("bright", "OpenCode Browser v2.1"))}
115
- ${color("cyan", "Browser automation MCP server for OpenCode")}
116
- `);
117
- }
118
-
119
- async function serve() {
120
- // Launch the MCP server
121
- const serverPath = join(PACKAGE_ROOT, "src", "mcp-server.ts");
122
-
123
- // Use bun to run the TypeScript server
124
- const child = spawn("bun", ["run", serverPath], {
125
- stdio: "inherit",
126
- env: process.env,
127
- });
128
-
129
- child.on("error", (err) => {
130
- console.error("[browser-mcp] Failed to start server:", err);
131
- process.exit(1);
132
- });
133
190
 
134
- child.on("exit", (code) => {
135
- process.exit(code || 0);
136
- });
137
-
138
- // Forward signals to child
139
- process.on("SIGINT", () => child.kill("SIGINT"));
140
- process.on("SIGTERM", () => child.kill("SIGTERM"));
191
+ rl.close();
141
192
  }
142
193
 
143
194
  async function install() {
144
195
  header("Step 1: Check Platform");
145
196
 
146
- const os = platform();
147
- if (os !== "darwin" && os !== "linux") {
148
- error(`Unsupported platform: ${os}`);
197
+ const osName = platform();
198
+ if (osName !== "darwin" && osName !== "linux") {
199
+ error(`Unsupported platform: ${osName}`);
149
200
  error("OpenCode Browser currently supports macOS and Linux only.");
150
201
  process.exit(1);
151
202
  }
152
- success(`Platform: ${os === "darwin" ? "macOS" : "Linux"}`);
203
+ success(`Platform: ${osName === "darwin" ? "macOS" : "Linux"}`);
153
204
 
154
205
  header("Step 2: Copy Extension Files");
155
206
 
156
- const extensionDir = join(homedir(), ".opencode-browser", "extension");
207
+ ensureDir(BASE_DIR);
157
208
  const srcExtensionDir = join(PACKAGE_ROOT, "extension");
209
+ copyDirRecursive(srcExtensionDir, EXTENSION_DIR);
210
+ success(`Extension files copied to: ${EXTENSION_DIR}`);
158
211
 
159
- mkdirSync(extensionDir, { recursive: true });
212
+ header("Step 3: Load & Pin Extension");
160
213
 
161
- const files = readdirSync(srcExtensionDir, { recursive: true });
162
- for (const file of files) {
163
- const srcPath = join(srcExtensionDir, file);
164
- const destPath = join(extensionDir, file);
214
+ log(`
215
+ To load the extension:
165
216
 
166
- try {
167
- const stat = readdirSync(srcPath);
168
- mkdirSync(destPath, { recursive: true });
169
- } catch {
170
- mkdirSync(dirname(destPath), { recursive: true });
171
- copyFileSync(srcPath, destPath);
172
- }
173
- }
217
+ 1. Open ${color("cyan", "chrome://extensions")}
218
+ 2. Enable ${color("bright", "Developer mode")}
219
+ 3. Click ${color("bright", "Load unpacked")}
220
+ 4. Select:
221
+ ${color("cyan", EXTENSION_DIR)}
222
+
223
+ After loading, ${color("bright", "pin the extension")}: open the Extensions menu (puzzle icon) and click the pin.
224
+ `);
174
225
 
175
- success(`Extension files copied to: ${extensionDir}`);
226
+ await ask(color("bright", "Press Enter when you've loaded and pinned the extension..."));
176
227
 
177
- header("Step 3: Load Extension in Chrome");
228
+ header("Step 4: Get Extension ID");
178
229
 
179
230
  log(`
180
- Works with: ${color("cyan", "Chrome")}, ${color("cyan", "Brave")}, ${color("cyan", "Arc")}, ${color("cyan", "Edge")}, and other Chromium browsers.
231
+ We need the extension ID to register the native messaging host.
181
232
 
182
- To load the extension:
233
+ Find it at ${color("cyan", "chrome://extensions")}:
234
+ - Locate ${color("bright", "OpenCode Browser Automation")}
235
+ - Click ${color("bright", "Details")}
236
+ - Copy the ${color("bright", "ID")}
237
+ `);
183
238
 
184
- 1. Open your browser and go to: ${color("cyan", "chrome://extensions")}
185
- (or ${color("cyan", "brave://extensions")}, ${color("cyan", "arc://extensions")}, etc.)
239
+ const extensionId = await ask(color("bright", "Paste Extension ID: "));
240
+ if (!/^[a-p]{32}$/i.test(extensionId)) {
241
+ warn("That doesn't look like a Chrome extension ID (expected 32 chars a-p). Continuing anyway.");
242
+ }
186
243
 
187
- 2. Enable ${color("bright", "Developer mode")} (toggle in top right)
244
+ header("Step 5: Install Local Host + Broker");
188
245
 
189
- 3. Click ${color("bright", "Load unpacked")}
246
+ const brokerSrc = join(PACKAGE_ROOT, "bin", "broker.cjs");
247
+ const nativeHostSrc = join(PACKAGE_ROOT, "bin", "native-host.cjs");
190
248
 
191
- 4. Select this folder:
192
- ${color("cyan", extensionDir)}
193
- ${os === "darwin" ? color("yellow", "Tip: Press Cmd+Shift+G and paste the path above") : ""}
194
- `);
249
+ copyFileSync(brokerSrc, BROKER_DST);
250
+ copyFileSync(nativeHostSrc, NATIVE_HOST_DST);
195
251
 
196
- await ask(color("bright", "Press Enter when you've loaded the extension..."));
252
+ try {
253
+ chmodSync(BROKER_DST, 0o755);
254
+ } catch {}
255
+ try {
256
+ chmodSync(NATIVE_HOST_DST, 0o755);
257
+ } catch {}
197
258
 
198
- header("Step 4: Configure OpenCode");
259
+ success(`Installed broker: ${BROKER_DST}`);
260
+ success(`Installed native host: ${NATIVE_HOST_DST}`);
199
261
 
200
- const mcpConfig = {
201
- browser: {
202
- type: "local",
203
- command: ["bunx", "@different-ai/opencode-browser", "serve"],
204
- },
205
- };
262
+ saveConfig({ extensionId, installedAt: new Date().toISOString() });
206
263
 
207
- log(`
208
- Add the MCP server to your ${color("cyan", "opencode.json")}:
264
+ header("Step 6: Register Native Messaging Host");
209
265
 
210
- ${color("bright", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: mcpConfig }, null, 2))}
266
+ const hostDirs = getNativeHostDirs(osName);
267
+ for (const dir of hostDirs) {
268
+ try {
269
+ writeNativeHostManifest(dir, extensionId);
270
+ success(`Wrote native host manifest: ${nativeHostManifestPath(dir)}`);
271
+ } catch (e) {
272
+ warn(`Could not write native host manifest to: ${dir}`);
273
+ }
274
+ }
211
275
 
212
- Or if you already have an opencode.json, add to the "mcp" object:
213
- ${color("bright", JSON.stringify({ mcp: mcpConfig }, null, 2))}
214
- `);
276
+ header("Step 7: Configure OpenCode");
215
277
 
216
278
  const opencodeJsonPath = join(process.cwd(), "opencode.json");
217
279
 
218
- if (existsSync(opencodeJsonPath)) {
219
- const shouldUpdate = await confirm(`Found opencode.json. Add MCP server automatically?`);
280
+ const desiredPlugin = "@different-ai/opencode-browser";
220
281
 
282
+ if (existsSync(opencodeJsonPath)) {
283
+ const shouldUpdate = await confirm("Found opencode.json. Add plugin automatically?");
221
284
  if (shouldUpdate) {
222
285
  try {
223
286
  const config = JSON.parse(readFileSync(opencodeJsonPath, "utf-8"));
224
- config.mcp = config.mcp || {};
225
- config.mcp.browser = mcpConfig.browser;
226
-
227
- // Remove old plugin config if present
228
- if (config.plugin && Array.isArray(config.plugin)) {
229
- const idx = config.plugin.indexOf("@different-ai/opencode-browser");
230
- if (idx !== -1) {
231
- config.plugin.splice(idx, 1);
232
- warn("Removed old plugin entry (replaced by MCP)");
233
- }
234
- if (config.plugin.length === 0) {
235
- delete config.plugin;
236
- }
287
+ config.plugin = config.plugin || [];
288
+ if (!Array.isArray(config.plugin)) config.plugin = [];
289
+ if (!config.plugin.includes(desiredPlugin)) config.plugin.push(desiredPlugin);
290
+
291
+ // Remove MCP config if present
292
+ if (config.mcp?.browser) {
293
+ delete config.mcp.browser;
294
+ if (Object.keys(config.mcp).length === 0) delete config.mcp;
295
+ warn("Removed old MCP browser config (replaced by plugin)");
237
296
  }
238
-
297
+
239
298
  writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
240
- success("Updated opencode.json with MCP server");
299
+ success("Updated opencode.json with plugin");
241
300
  } catch (e) {
242
301
  error(`Failed to update opencode.json: ${e.message}`);
243
- log("Please add the MCP config manually.");
244
302
  }
245
303
  }
246
304
  } else {
247
- const shouldCreate = await confirm(`No opencode.json found. Create one?`);
248
-
305
+ const shouldCreate = await confirm("No opencode.json found. Create one?");
249
306
  if (shouldCreate) {
250
- try {
251
- const config = {
252
- $schema: "https://opencode.ai/config.json",
253
- mcp: mcpConfig,
254
- };
255
- writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
256
- success("Created opencode.json with MCP server");
257
- } catch (e) {
258
- error(`Failed to create opencode.json: ${e.message}`);
259
- }
307
+ const config = { $schema: "https://opencode.ai/config.json", plugin: [desiredPlugin] };
308
+ writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
309
+ success("Created opencode.json with plugin");
260
310
  }
261
311
  }
262
312
 
263
- // Clean up old daemon/plugin if present
264
- header("Step 5: Cleanup (migration)");
265
-
266
- const oldDaemonPlist = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");
267
- if (existsSync(oldDaemonPlist)) {
268
- try {
269
- execSync(`launchctl unload "${oldDaemonPlist}" 2>/dev/null || true`, { stdio: "ignore" });
270
- unlinkSync(oldDaemonPlist);
271
- success("Removed old daemon (no longer needed)");
272
- } catch {
273
- warn("Could not remove old daemon plist. Remove manually if needed.");
274
- }
275
- }
276
-
277
- // Remove old lock file
278
- const oldLockFile = join(homedir(), ".opencode-browser", "lock.json");
279
- if (existsSync(oldLockFile)) {
280
- try {
281
- unlinkSync(oldLockFile);
282
- success("Removed old lock file (not needed with MCP)");
283
- } catch {}
284
- }
285
-
286
- success("Cleanup complete");
287
-
288
313
  header("Installation Complete!");
289
314
 
290
315
  log(`
291
- ${color("green", "")} Extension: ${extensionDir}
292
- ${color("green", "")} MCP Server: @different-ai/opencode-browser
293
-
294
- ${color("bright", "How it works:")}
295
- 1. OpenCode spawns MCP server on demand
296
- 2. MCP server starts WebSocket server on port 19222
297
- 3. Chrome extension connects automatically
298
- 4. Browser tools are available to any OpenCode session!
299
-
300
- ${color("bright", "Available tools:")}
301
- browser_status - Check if browser is connected
302
- browser_navigate - Go to a URL
303
- browser_click - Click an element
304
- browser_type - Type into an input
305
- browser_screenshot - Capture the page
306
- browser_snapshot - Get accessibility tree + all links
307
- browser_get_tabs - List open tabs
308
- browser_scroll - Scroll the page
309
- browser_wait - Wait for duration
310
- browser_execute - Run JavaScript
311
-
312
- ${color("bright", "Benefits of MCP architecture:")}
313
- - No session conflicts between OpenCode instances
314
- - Server runs independently of OpenCode process
315
- - Clean separation of concerns
316
-
317
- ${color("bright", "Test it:")}
318
- Restart OpenCode and try: ${color("cyan", '"Check browser status"')}
316
+ ${color("bright", "What happens now:")}
317
+ - The extension connects to the native host automatically.
318
+ - OpenCode loads the plugin, which talks to the broker.
319
+ - The broker enforces ${color("bright", "per-tab ownership")}. First touch auto-claims.
320
+
321
+ ${color("bright", "Try it:")}
322
+ Restart OpenCode and run: ${color("cyan", "browser_get_tabs")}
319
323
  `);
320
324
  }
321
325
 
322
326
  async function status() {
323
- header("Browser Status");
327
+ header("Status");
324
328
 
325
- // Check if port 19222 is in use
326
- try {
327
- const result = execSync("lsof -i :19222 2>/dev/null || true", { encoding: "utf-8" });
328
- if (result.trim()) {
329
- success("WebSocket server is running on port 19222");
330
- log(result);
331
- } else {
332
- warn("WebSocket server not running (starts on demand via MCP)");
333
- }
334
- } catch {
335
- warn("Could not check port status");
336
- }
329
+ success(`Base dir: ${BASE_DIR}`);
330
+ success(`Extension dir present: ${existsSync(EXTENSION_DIR)}`);
331
+ success(`Broker installed: ${existsSync(BROKER_DST)}`);
332
+ success(`Native host installed: ${existsSync(NATIVE_HOST_DST)}`);
337
333
 
338
- // Check extension directory
339
- const extensionDir = join(homedir(), ".opencode-browser", "extension");
340
- if (existsSync(extensionDir)) {
341
- success(`Extension installed at: ${extensionDir}`);
334
+ const cfg = loadConfig();
335
+ if (cfg?.extensionId) {
336
+ success(`Configured extension ID: ${cfg.extensionId}`);
342
337
  } else {
343
- warn("Extension not installed. Run: npx @different-ai/opencode-browser install");
338
+ warn("No config.json found (run install)");
344
339
  }
345
- }
346
340
 
347
- async function uninstall() {
348
- header("Uninstalling OpenCode Browser");
349
-
350
- // Remove old daemon
351
- const os = platform();
352
- if (os === "darwin") {
353
- const plistPath = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");
354
- if (existsSync(plistPath)) {
355
- try {
356
- execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: "ignore" });
357
- unlinkSync(plistPath);
358
- success("Removed daemon plist");
359
- } catch {}
341
+ const osName = platform();
342
+ const hostDirs = getNativeHostDirs(osName);
343
+ let foundAny = false;
344
+ for (const dir of hostDirs) {
345
+ const p = nativeHostManifestPath(dir);
346
+ if (existsSync(p)) {
347
+ foundAny = true;
348
+ success(`Native host manifest: ${p}`);
360
349
  }
361
350
  }
351
+ if (!foundAny) {
352
+ warn("No native host manifest found. Run: npx @different-ai/opencode-browser install");
353
+ }
354
+ }
362
355
 
363
- // Remove native host registration (v1.x)
364
- const nativeHostDir =
365
- os === "darwin"
366
- ? join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts")
367
- : join(homedir(), ".config", "google-chrome", "NativeMessagingHosts");
356
+ async function uninstall() {
357
+ header("Uninstall");
368
358
 
369
- const manifestPath = join(nativeHostDir, "com.opencode.browser_automation.json");
370
- if (existsSync(manifestPath)) {
371
- unlinkSync(manifestPath);
372
- success("Removed native host registration");
359
+ const osName = platform();
360
+ const hostDirs = getNativeHostDirs(osName);
361
+ for (const dir of hostDirs) {
362
+ const p = nativeHostManifestPath(dir);
363
+ if (!existsSync(p)) continue;
364
+ try {
365
+ unlinkSync(p);
366
+ success(`Removed native host manifest: ${p}`);
367
+ } catch {
368
+ warn(`Could not remove: ${p}`);
369
+ }
373
370
  }
374
371
 
375
- // Remove lock file
376
- const lockFile = join(homedir(), ".opencode-browser", "lock.json");
377
- if (existsSync(lockFile)) {
378
- unlinkSync(lockFile);
379
- success("Removed lock file");
372
+ for (const p of [BROKER_DST, NATIVE_HOST_DST, CONFIG_DST, join(BASE_DIR, "broker.sock")]) {
373
+ if (!existsSync(p)) continue;
374
+ try {
375
+ unlinkSync(p);
376
+ success(`Removed: ${p}`);
377
+ } catch {
378
+ // ignore
379
+ }
380
380
  }
381
381
 
382
382
  log(`
383
- ${color("bright", "Note:")} Extension files at ~/.opencode-browser/ were not removed.
384
- Remove manually if needed:
385
- rm -rf ~/.opencode-browser/
386
-
387
- Also remove the "browser" entry from your opencode.json mcp section.
383
+ ${color("bright", "Note:")}
384
+ - The unpacked extension folder remains at: ${EXTENSION_DIR}
385
+ - Remove it manually in ${color("cyan", "chrome://extensions")}
386
+ - Remove ${color("bright", "@different-ai/opencode-browser")} from your opencode.json plugin list if desired.
388
387
  `);
389
388
  }
390
389
 
391
390
  main().catch((e) => {
392
- error(e.message);
391
+ error(e.message || String(e));
393
392
  process.exit(1);
394
393
  });