@browserbridge/bbx 1.7.4 → 1.7.6

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.
Files changed (34) hide show
  1. package/package.json +6 -6
  2. package/packages/agent-client/src/atomic-write.js +99 -0
  3. package/packages/agent-client/src/cli-args.js +152 -0
  4. package/packages/agent-client/src/cli-batch.js +140 -0
  5. package/packages/agent-client/src/cli-helpers.js +20 -3
  6. package/packages/agent-client/src/cli-output.js +57 -0
  7. package/packages/agent-client/src/cli-proxy-remote.js +350 -0
  8. package/packages/agent-client/src/cli-setup-commands.js +404 -0
  9. package/packages/agent-client/src/cli.js +63 -906
  10. package/packages/agent-client/src/command-registry.js +12 -6
  11. package/packages/agent-client/src/install.js +39 -4
  12. package/packages/agent-client/src/mcp-config.js +28 -14
  13. package/packages/agent-client/src/remotes.js +93 -15
  14. package/packages/agent-client/src/runtime.js +6 -4
  15. package/packages/mcp-server/src/bin.js +10 -1
  16. package/packages/mcp-server/src/guidance.js +1 -1
  17. package/packages/mcp-server/src/handlers-capture.js +8 -5
  18. package/packages/mcp-server/src/handlers-dom.js +2 -2
  19. package/packages/mcp-server/src/handlers-navigation.js +20 -9
  20. package/packages/mcp-server/src/handlers-page.js +111 -111
  21. package/packages/mcp-server/src/handlers-utils.js +268 -8
  22. package/packages/mcp-server/src/handlers.js +31 -10
  23. package/packages/mcp-server/src/lifecycle.js +413 -0
  24. package/packages/mcp-server/src/server.js +27 -19
  25. package/packages/native-host/src/auth-token.js +1 -1
  26. package/packages/native-host/src/config.js +9 -4
  27. package/packages/native-host/src/daemon-process.js +8 -1
  28. package/packages/protocol/src/capabilities.js +33 -110
  29. package/packages/protocol/src/capability-values.js +45 -0
  30. package/packages/protocol/src/defaults.js +19 -2
  31. package/packages/protocol/src/protocol.js +106 -5
  32. package/packages/protocol/src/registry.js +299 -86
  33. package/packages/protocol/src/types.ts +19 -1
  34. package/skills/browser-bridge/SKILL.md +1 -1
@@ -4,74 +4,42 @@
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { randomUUID } from 'node:crypto';
8
7
  import { fileURLToPath } from 'node:url';
9
8
 
10
- import {
11
- applyWindowsTcpTransportDefaults,
12
- createTcpBridgeTransport,
13
- getProxyConfigPath,
14
- readProxyConfig,
15
- SUPPORTED_BROWSERS,
16
- } from '../../native-host/src/config.js';
17
- import { writeBridgeAuthToken } from '../../native-host/src/auth-token.js';
18
- import { pingExistingDaemon } from '../../native-host/src/daemon.js';
9
+ import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
19
10
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
20
- import { uninstallNativeManifest } from '../../native-host/src/install-manifest.js';
11
+ import { createRuntimeContext, METHODS } from '../../protocol/src/index.js';
21
12
  import {
22
- createRuntimeContext,
23
- getProtocolVersion,
24
- METHODS,
25
- summarizeBatchErrorItem,
26
- summarizeBatchResponseItem,
27
- } from '../../protocol/src/index.js';
13
+ restartRegisteredMcpProcesses,
14
+ tryStartMcpProcessControl,
15
+ } from '../../mcp-server/src/lifecycle.js';
28
16
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
29
- import { CLI_HELP_SECTIONS, SHORTCUT_COMMANDS } from './command-registry.js';
30
- import {
31
- interactiveCheckbox,
32
- interactiveConfirm,
33
- methodNeedsTab,
34
- parseIntArg,
35
- parseJsonObject,
36
- sanitizeOutput,
37
- } from './cli-helpers.js';
38
17
  import {
39
- findInstalledManagedTargets,
40
- installAgentFiles,
41
- installMcpClientSetup,
42
- parseInstallAgentArgs,
43
- removeAgentFiles,
44
- SUPPORTED_TARGETS,
45
- TARGET_LABELS,
46
- } from './install.js';
18
+ extractRemoteFlag,
19
+ extractTabFlag,
20
+ parseCallCommand,
21
+ parseInterceptAddArgs,
22
+ readStdin,
23
+ } from './cli-args.js';
24
+ import { runBatchCalls } from './cli-batch.js';
25
+ import { parseIntArg } from './cli-helpers.js';
26
+ import { printCallResponse, printJson, printSummary, printUsage } from './cli-output.js';
27
+ import { handleProxyCommand, handleRemoteCommand } from './cli-proxy-remote.js';
47
28
  import {
48
- findConfiguredMcpClients,
49
- formatMcpConfig,
50
- isMcpClientName,
51
- MCP_CLIENT_LABELS,
52
- MCP_CLIENT_NAMES,
53
- removeMcpConfig,
54
- } from './mcp-config.js';
29
+ runInstallMcpCommand,
30
+ runInstallSkillCommand,
31
+ uninstallBrowserBridge,
32
+ } from './cli-setup-commands.js';
33
+ import { SHORTCUT_COMMANDS } from './command-registry.js';
34
+ import { formatMcpConfig, isMcpClientName, MCP_CLIENT_NAMES } from './mcp-config.js';
55
35
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
56
- import {
57
- addRemoteDestination,
58
- createBridgeClientForDestination,
59
- listBridgeDestinations,
60
- normalizeDestinationId,
61
- parseRemoteEndpoint,
62
- readRemoteConfig,
63
- removeRemoteDestination,
64
- resolveProxyEnableSettings,
65
- } from './remotes.js';
66
- import { collectSetupStatus } from './setup-status.js';
67
- import { annotateBridgeSummary, summarizeBridgeResponse } from './subagent.js';
36
+ import { createBridgeClientForDestination } from './remotes.js';
68
37
 
69
38
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
70
39
  /** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
71
40
 
72
41
  const REQUEST_SOURCE = 'cli';
73
42
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
74
- const TEST_DETECTED_MCP_CLIENTS_ENV = 'BBX_TEST_DETECTED_MCP_CLIENTS';
75
43
  const REMOTE_ENV = 'BBX_REMOTE';
76
44
 
77
45
  /**
@@ -99,25 +67,6 @@ const LOCAL_ONLY_COMMANDS = new Set([
99
67
  'restart',
100
68
  ]);
101
69
 
102
- /**
103
- * Read all of stdin as UTF-8 text. Resolves once stdin closes.
104
- *
105
- * @returns {Promise<string>}
106
- */
107
- function readStdin() {
108
- return new Promise((resolve, reject) => {
109
- const chunks = /** @type {Buffer[]} */ ([]);
110
- process.stdin.setEncoding('utf8');
111
- process.stdin.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
112
- process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim()));
113
- process.stdin.on('error', reject);
114
- // If stdin is a TTY and nothing is piped, read nothing
115
- if (process.stdin.isTTY) {
116
- resolve('');
117
- }
118
- });
119
- }
120
-
121
70
  const remoteFlag = extractRemoteFlag(process.argv.slice(2));
122
71
  const [command, ...rest] = remoteFlag.rest;
123
72
 
@@ -155,7 +104,6 @@ if (command === 'skill') {
155
104
 
156
105
  if (command === 'install') {
157
106
  const { execFileSync } = await import('node:child_process');
158
- const { fileURLToPath } = await import('node:url');
159
107
  const installScript = path.resolve(
160
108
  path.dirname(fileURLToPath(import.meta.url)),
161
109
  '../../native-host/bin/install-manifest.js'
@@ -177,250 +125,23 @@ if (command === 'uninstall') {
177
125
  }
178
126
 
179
127
  if (command === 'install-skill') {
180
- // When no positional target is given, detect installed agents and prompt.
181
- const positional = rest.filter((a) => !a.startsWith('--'));
182
-
183
- if (positional.length === 0) {
184
- let scopeOptions;
185
- try {
186
- scopeOptions = parseInstallAgentArgs(rest);
187
- } catch (error) {
188
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
189
- process.exit(1);
190
- }
191
-
192
- const isGlobal = scopeOptions.global !== false;
193
- const projectPath = isGlobal ? os.homedir() : scopeOptions.projectPath;
194
-
195
- const setupStatus = await collectSetupStatus({
196
- global: isGlobal,
197
- cwd: process.cwd(),
198
- projectPath,
199
- ...getSetupStatusTestOverrides(),
200
- });
201
- /** @type {import('./types.js').SupportedTarget[]} */
202
- const detected = /** @type {import('./types.js').SupportedTarget[]} */ (
203
- setupStatus.skillTargets.filter((entry) => entry.detected).map((entry) => entry.key)
204
- );
205
- const installedManagedTargets = new Set(
206
- setupStatus.skillTargets
207
- .filter((entry) => entry.installed && entry.managed)
208
- .map((entry) => entry.key)
209
- );
210
- const installedManagedTargetList =
211
- /** @type {import('./types.js').SupportedTarget[]} */ ([...installedManagedTargets]);
212
-
213
- // Aliases like 'openai' and 'google' map to canonical targets and stay omitted.
214
- const items = SUPPORTED_TARGETS.map((t) => ({
215
- value: t,
216
- label: `${t.padEnd(10)} ${TARGET_LABELS[t]}`,
217
- hint: formatSelectionHint({
218
- detected: detected.includes(t),
219
- installed: installedManagedTargets.has(t),
220
- }),
221
- checked: installedManagedTargets.has(t),
222
- }));
223
-
224
- const selected = await interactiveCheckbox(
225
- 'Select agents to install skill for (↑↓ move · space toggle · a all · enter confirm)',
226
- items
227
- );
228
-
229
- /** @type {import('./types.js').SupportedTarget[]} */
230
- let targets;
231
- if (selected === null) {
232
- // Non-TTY: prefer managed installs, then detected targets (always includes 'agents').
233
- targets = installedManagedTargets.size > 0 ? installedManagedTargetList : detected;
234
- } else {
235
- targets = /** @type {import('./types.js').SupportedTarget[]} */ (selected);
236
- }
237
-
238
- if (selected !== null) {
239
- const deselectedTargets =
240
- /** @type {import('./types.js').SupportedTarget[]} */ (
241
- installedManagedTargetList.filter((target) => !targets.includes(target))
242
- );
243
- const removableTargets = await findInstalledManagedTargets({
244
- targets: deselectedTargets,
245
- projectPath,
246
- global: isGlobal,
247
- });
248
- if (removableTargets.length > 0) {
249
- const confirmed = await interactiveConfirm(
250
- `Remove Browser Bridge skill from deselected targets: ${removableTargets.join(', ')}?`
251
- );
252
- if (confirmed) {
253
- const removedPaths = await removeAgentFiles({
254
- targets: removableTargets,
255
- projectPath,
256
- global: isGlobal,
257
- });
258
- for (const p of removedPaths) process.stdout.write(`Removed ${p}\n`);
259
- }
260
- }
261
- }
262
-
263
- if (targets.length === 0) {
264
- process.stdout.write('No targets selected.\n');
265
- process.exit(0);
266
- }
267
-
268
- const installedPaths = await installAgentFiles({
269
- targets,
270
- projectPath,
271
- global: isGlobal,
272
- });
273
- for (const p of installedPaths) process.stdout.write(`Installed ${p}\n`);
274
- process.exit(0);
275
- }
276
-
277
- // Explicit targets or 'all' provided - use existing arg-parsing logic.
278
- const options = parseInstallAgentArgs(rest);
279
- const installedPaths = await installAgentFiles(options);
280
- for (const installedPath of installedPaths) {
281
- process.stdout.write(`Installed ${installedPath}\n`);
282
- }
283
- process.exit(0);
128
+ await runInstallSkillCommand(rest);
284
129
  }
285
130
 
286
131
  if (command === 'install-mcp') {
287
- let isGlobal = true;
288
- /** @type {string[]} */
289
- const positionals = [];
290
-
291
- for (const arg of rest) {
292
- if (arg === '--local') {
293
- isGlobal = false;
294
- continue;
295
- }
296
- if (arg === '--global') {
297
- isGlobal = true;
298
- continue;
299
- }
300
- if (arg.startsWith('--')) {
301
- process.stderr.write(`Unknown install-mcp option "${arg}".\n`);
302
- process.exit(1);
303
- }
304
- positionals.push(arg);
305
- }
306
-
307
- if (positionals.length > 1) {
308
- process.stderr.write(`Unexpected extra argument "${positionals[1]}".\n`);
309
- process.exit(1);
310
- }
311
-
312
- const clientArg = positionals[0];
313
-
314
- /** @type {import('./types.js').McpClientName[]} */
315
- let clients;
316
-
317
- if (!clientArg) {
318
- // No client specified: inspect current MCP config and prompt interactively.
319
- const setupStatus = await collectSetupStatus({
320
- global: isGlobal,
321
- cwd: process.cwd(),
322
- projectPath: process.cwd(),
323
- ...getSetupStatusTestOverrides(),
324
- });
325
- const detected = /** @type {import('./types.js').McpClientName[]} */ (
326
- setupStatus.mcpClients.filter((entry) => entry.detected).map((entry) => entry.key)
327
- );
328
- const configuredClients = new Set(
329
- setupStatus.mcpClients.filter((entry) => entry.configured).map((entry) => entry.key)
330
- );
331
- const configuredClientList =
332
- /** @type {import('./types.js').McpClientName[]} */ ([...configuredClients]);
333
- const items = MCP_CLIENT_NAMES.map((c) => ({
334
- value: c,
335
- label: `${c.padEnd(10)} ${MCP_CLIENT_LABELS[c]}`,
336
- hint: formatSelectionHint({
337
- detected: detected.includes(c),
338
- installed: configuredClients.has(c),
339
- }),
340
- checked: configuredClients.has(c),
341
- }));
342
-
343
- const selected = await interactiveCheckbox(
344
- 'Select clients to configure (↑↓ move · space toggle · a all · enter confirm)',
345
- items
346
- );
347
-
348
- if (selected === null) {
349
- // Non-TTY: prefer configured clients, then detected clients, then all.
350
- clients =
351
- configuredClients.size > 0
352
- ? configuredClientList
353
- : detected.length > 0
354
- ? detected
355
- : [...MCP_CLIENT_NAMES];
356
- } else {
357
- clients = /** @type {import('./types.js').McpClientName[]} */ (selected);
358
- }
359
-
360
- if (selected !== null) {
361
- const deselectedClients =
362
- /** @type {import('./types.js').McpClientName[]} */ (
363
- configuredClientList.filter((clientName) => !clients.includes(clientName))
364
- );
365
- const removableClients = await findConfiguredMcpClients({
366
- clients: deselectedClients,
367
- global: isGlobal,
368
- cwd: process.cwd(),
369
- });
370
- if (removableClients.length > 0) {
371
- const confirmed = await interactiveConfirm(
372
- `Remove Browser Bridge MCP config from deselected clients: ${removableClients.join(', ')}?`
373
- );
374
- if (confirmed) {
375
- for (const clientName of removableClients) {
376
- await removeMcpConfig(clientName, {
377
- global: isGlobal,
378
- cwd: process.cwd(),
379
- });
380
- }
381
- }
382
- }
383
- }
384
-
385
- if (clients.length === 0) {
386
- process.stdout.write('No clients selected.\n');
387
- process.exit(0);
388
- }
389
- } else if (clientArg === 'all') {
390
- clients = [...MCP_CLIENT_NAMES];
391
- } else {
392
- const parts = clientArg
393
- .split(',')
394
- .map((s) => s.trim().toLowerCase())
395
- .filter(Boolean);
396
- if (parts.includes('all')) {
397
- clients = [...MCP_CLIENT_NAMES];
398
- } else {
399
- clients = [];
400
- for (const part of parts) {
401
- if (!isMcpClientName(part)) {
402
- process.stderr.write(
403
- `Unknown client "${part}". Supported: ${MCP_CLIENT_NAMES.join(', ')}, all\n`
404
- );
405
- process.exit(1);
406
- }
407
- clients.push(part);
408
- }
409
- }
410
- }
411
-
412
- await installMcpClientSetup(clients, {
413
- global: isGlobal,
414
- projectPath: process.cwd(),
415
- stdout: process.stdout,
416
- });
417
- process.exit(0);
132
+ await runInstallMcpCommand(rest);
418
133
  }
419
134
 
420
135
  if (command === 'mcp') {
421
136
  const [subcommand, clientName] = rest;
422
137
  if (subcommand === 'serve') {
423
- await startBridgeMcpServer();
138
+ const control = await tryStartMcpProcessControl();
139
+ try {
140
+ await startBridgeMcpServer();
141
+ } catch (error) {
142
+ await control?.dispose();
143
+ throw error;
144
+ }
424
145
  await new Promise(() => {});
425
146
  }
426
147
  if (subcommand === 'config') {
@@ -528,12 +249,26 @@ async function main() {
528
249
 
529
250
  if (command === 'restart') {
530
251
  const result = await restartBridgeDaemon();
252
+ const mcpProcesses = await restartRegisteredMcpProcesses();
253
+ if (mcpProcesses.restartFailed > 0) {
254
+ process.exitCode = 1;
255
+ }
531
256
  printJson({
532
- ok: true,
533
- summary: result.previouslyRunning
534
- ? 'Browser Bridge daemon restarted.'
535
- : 'Browser Bridge daemon started.',
536
- evidence: result,
257
+ ok: mcpProcesses.restartFailed === 0,
258
+ summary: `${
259
+ result.previouslyRunning
260
+ ? 'Browser Bridge daemon restarted.'
261
+ : 'Browser Bridge daemon started.'
262
+ }${
263
+ mcpProcesses.restartRequested > 0
264
+ ? ` Restart requested for ${mcpProcesses.restartRequested} MCP server(s).`
265
+ : ''
266
+ }${
267
+ mcpProcesses.restartFailed > 0
268
+ ? ` Could not contact ${mcpProcesses.restartFailed} MCP server(s).`
269
+ : ''
270
+ }`,
271
+ evidence: { ...result, mcpProcesses },
537
272
  });
538
273
  return;
539
274
  }
@@ -607,83 +342,10 @@ async function main() {
607
342
  }
608
343
 
609
344
  if (command === 'batch') {
610
- const input = rest[0];
611
- if (!input) {
612
- throw new Error('Usage: batch \'[{"method":"...","params":{...}}, ...]\'');
613
- }
614
- let calls;
615
- try {
616
- calls = JSON.parse(input);
617
- } catch {
618
- throw new Error('Invalid JSON syntax. Expected a JSON array of bridge calls.');
619
- }
620
- if (!Array.isArray(calls)) {
621
- throw new Error('Batch input must be a JSON array.');
345
+ const results = await runBatchCalls(client, rest[0], REQUEST_SOURCE);
346
+ if (results.some((result) => !result.ok)) {
347
+ process.exitCode = 1;
622
348
  }
623
- await ensureClientConnection();
624
- const results = await Promise.all(
625
- calls.map(async (call) => {
626
- if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
627
- return {
628
- method: '',
629
- tabId: null,
630
- ok: false,
631
- summary: 'INVALID_REQUEST: Each batch call needs a method.',
632
- evidence: null,
633
- durationMs: 0,
634
- approxTokens: 0,
635
- meta: { protocol_version: getProtocolVersion() },
636
- error: {
637
- code: 'INVALID_REQUEST',
638
- message: 'Each batch call needs a method.',
639
- },
640
- response: null,
641
- };
642
- }
643
- if (!METHODS.includes(/** @type {BridgeMethod} */ (call.method))) {
644
- return {
645
- method: call.method,
646
- tabId: null,
647
- ok: false,
648
- summary: `INVALID_REQUEST: Unknown bridge method "${call.method}".`,
649
- evidence: null,
650
- durationMs: 0,
651
- approxTokens: 0,
652
- meta: { protocol_version: getProtocolVersion() },
653
- error: {
654
- code: 'INVALID_REQUEST',
655
- message: `Unknown bridge method "${call.method}".`,
656
- },
657
- response: null,
658
- };
659
- }
660
- const method = /** @type {BridgeMethod} */ (call.method);
661
- const tabId =
662
- methodNeedsTab(call.method) && typeof call.tabId === 'number' ? call.tabId : null;
663
- const startTime = Date.now();
664
- try {
665
- const response = await client.request({
666
- method,
667
- tabId,
668
- params: call.params || {},
669
- meta: { source: REQUEST_SOURCE },
670
- });
671
- return summarizeBatchResponseItem({
672
- method,
673
- tabId,
674
- response,
675
- durationMs: Date.now() - startTime,
676
- });
677
- } catch (err) {
678
- return summarizeBatchErrorItem({
679
- method,
680
- tabId,
681
- error: err,
682
- durationMs: Date.now() - startTime,
683
- });
684
- }
685
- })
686
- );
687
349
  printJson(results);
688
350
  return;
689
351
  }
@@ -817,22 +479,7 @@ async function main() {
817
479
  const { tabId, rest: iArgs } = extractTabFlag(rest);
818
480
  const sub = iArgs[0];
819
481
  if (sub === 'add') {
820
- const pattern = iArgs[1];
821
- if (!pattern)
822
- throw new Error(
823
- 'Usage: intercept add <urlPattern> [--respond <body>] [--status <code>] [--block]'
824
- );
825
- const isBlock = iArgs.includes('--block');
826
- const statusIdx = iArgs.indexOf('--status');
827
- const statusCode = statusIdx !== -1 ? parseIntArg(iArgs[statusIdx + 1], 'status') : 200;
828
- const respondIdx = iArgs.indexOf('--respond');
829
- const body =
830
- respondIdx !== -1
831
- ? iArgs
832
- .slice(respondIdx + 1)
833
- .filter((a) => !a.startsWith('--'))
834
- .join(' ')
835
- : undefined;
482
+ const { pattern, isBlock, statusCode, body } = parseInterceptAddArgs(iArgs.slice(1));
836
483
  const response = await requestBridge(
837
484
  client,
838
485
  'network.intercept.add',
@@ -847,7 +494,9 @@ async function main() {
847
494
  await printSummary(response);
848
495
  } else if (sub === 'remove') {
849
496
  const ruleId = iArgs[1];
850
- if (!ruleId) throw new Error('Usage: intercept remove <ruleId>');
497
+ if (!ruleId || iArgs.length !== 2) {
498
+ throw new Error('Usage: intercept remove <ruleId>');
499
+ }
851
500
  const response = await requestBridge(
852
501
  client,
853
502
  'network.intercept.remove',
@@ -856,6 +505,9 @@ async function main() {
856
505
  );
857
506
  await printSummary(response);
858
507
  } else if (sub === 'list') {
508
+ if (iArgs.length !== 1) {
509
+ throw new Error('Usage: intercept list');
510
+ }
859
511
  const response = await requestBridge(
860
512
  client,
861
513
  'network.intercept.list',
@@ -864,6 +516,9 @@ async function main() {
864
516
  );
865
517
  await printSummary(response);
866
518
  } else if (sub === 'clear') {
519
+ if (iArgs.length !== 1) {
520
+ throw new Error('Usage: intercept clear');
521
+ }
867
522
  const response = await requestBridge(
868
523
  client,
869
524
  'network.intercept.clear',
@@ -931,291 +586,6 @@ async function main() {
931
586
  }
932
587
  }
933
588
 
934
- /**
935
- * @param {string[]} args
936
- * @returns {Promise<void>}
937
- */
938
- async function handleProxyCommand(args) {
939
- const [subcommand, ...restArgs] = args;
940
- if (subcommand === 'enable') {
941
- const options = parseProxyEnableArgs(restArgs);
942
- const existing = readProxyConfig();
943
- const { port, bindHost, token, tokenSource } = resolveProxyEnableSettings(
944
- existing,
945
- options,
946
- randomUUID
947
- );
948
- await writeBridgeAuthToken(token);
949
- const configPath = getProxyConfigPath();
950
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
951
- await fs.promises.writeFile(
952
- configPath,
953
- `${JSON.stringify(
954
- {
955
- enabled: true,
956
- port,
957
- bindHost,
958
- token,
959
- },
960
- null,
961
- 2
962
- )}\n`,
963
- { encoding: 'utf8', mode: 0o600 }
964
- );
965
- const result = await restartBridgeDaemon();
966
- const exampleHost = getProxyExampleHost(bindHost);
967
- const tokenNote =
968
- tokenSource === 'existing'
969
- ? ' (unchanged - already-configured clients keep working; pass --rotate-token to generate a new secret)'
970
- : tokenSource === 'generated' && existing
971
- ? ' (rotated - update every configured client with `bbx remote add`)'
972
- : '';
973
- const lines = [
974
- `Browser Bridge proxy enabled on ${bindHost}:${port}.`,
975
- '',
976
- `Token: ${token}${tokenNote}`,
977
- `Config: ${configPath}`,
978
- `Daemon: ${result.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
979
- ];
980
- if (tokenSource !== 'existing') {
981
- lines.push(
982
- '',
983
- 'On your dev machine, add this remote:',
984
- '',
985
- `bbx remote add <name> <host:port> --token ${token}`,
986
- '',
987
- 'Example:',
988
- '',
989
- `bbx remote add remote-bbx ${exampleHost}:${port} --token ${token}`
990
- );
991
- }
992
- lines.push('');
993
- process.stdout.write(lines.join('\n'));
994
- return;
995
- }
996
-
997
- if (subcommand === 'disable') {
998
- const configPath = getProxyConfigPath();
999
- await fs.promises.rm(configPath, { force: true });
1000
- const result = await restartBridgeDaemon();
1001
- process.stdout.write(
1002
- `Browser Bridge proxy disabled. Daemon ${result.previouslyRunning ? 'restarted' : 'started'}.\n`
1003
- );
1004
- return;
1005
- }
1006
-
1007
- if (subcommand === 'status') {
1008
- const config = readProxyConfig();
1009
- if (!config) {
1010
- process.stdout.write('Browser Bridge proxy is disabled.\n');
1011
- return;
1012
- }
1013
- const reachable = await pingExistingDaemon(createTcpBridgeTransport(config.port, '127.0.0.1'));
1014
- process.stdout.write(
1015
- [
1016
- `Browser Bridge proxy is enabled on ${config.bindHost}:${config.port}.`,
1017
- `Config: ${getProxyConfigPath()}`,
1018
- `Daemon: ${reachable ? 'reachable' : 'not reachable'} on 127.0.0.1:${config.port}`,
1019
- '',
1020
- ].join('\n')
1021
- );
1022
- return;
1023
- }
1024
-
1025
- throw new Error('Usage: bbx proxy <enable|disable|status>');
1026
- }
1027
-
1028
- /**
1029
- * Parse `bbx proxy enable` flags. Values stay undefined when not passed so an
1030
- * existing proxy config can be reused instead of silently reset to defaults.
1031
- *
1032
- * @param {string[]} args
1033
- * @returns {import('./remotes.js').ProxyEnableOptions}
1034
- */
1035
- function parseProxyEnableArgs(args) {
1036
- /** @type {import('./remotes.js').ProxyEnableOptions} */
1037
- const options = {};
1038
- for (let index = 0; index < args.length; index += 1) {
1039
- const arg = args[index];
1040
- if (arg === '--port') {
1041
- options.port = parseIntArg(args[++index] || '', 'port');
1042
- continue;
1043
- }
1044
- if (arg === '--bind-host') {
1045
- options.bindHost = args[++index] || '';
1046
- if (!options.bindHost.trim()) {
1047
- throw new Error('--bind-host requires a value.');
1048
- }
1049
- continue;
1050
- }
1051
- if (arg === '--token') {
1052
- options.token = args[++index] || '';
1053
- if (!options.token.trim()) {
1054
- throw new Error('--token requires a value.');
1055
- }
1056
- continue;
1057
- }
1058
- if (arg === '--rotate-token') {
1059
- options.rotateToken = true;
1060
- continue;
1061
- }
1062
- throw new Error(`Unknown proxy enable option "${arg}".`);
1063
- }
1064
- if (options.token && options.rotateToken) {
1065
- throw new Error('Use either --token or --rotate-token, not both.');
1066
- }
1067
- if (options.port !== undefined && (options.port < 1 || options.port > 65535)) {
1068
- throw new Error('port must be an integer between 1 and 65535.');
1069
- }
1070
- return options;
1071
- }
1072
-
1073
- /**
1074
- * @param {string} bindHost
1075
- * @returns {string}
1076
- */
1077
- function getProxyExampleHost(bindHost) {
1078
- if (bindHost !== '0.0.0.0' && bindHost !== '::') {
1079
- return bindHost;
1080
- }
1081
-
1082
- /** @type {string | null} */
1083
- let firstIpv4 = null;
1084
- for (const [name, entries] of Object.entries(os.networkInterfaces())) {
1085
- for (const entry of entries ?? []) {
1086
- if (!entry.internal && entry.family === 'IPv4') {
1087
- if (name.toLowerCase() === 'ethernet 0') {
1088
- return entry.address;
1089
- }
1090
- firstIpv4 ??= entry.address;
1091
- }
1092
- }
1093
- }
1094
-
1095
- return firstIpv4 ?? '<host>';
1096
- }
1097
-
1098
- /**
1099
- * @param {string[]} args
1100
- * @returns {Promise<void>}
1101
- */
1102
- async function handleRemoteCommand(args) {
1103
- const [subcommand, ...restArgs] = args;
1104
- if (subcommand === 'add') {
1105
- const [name, endpoint, ...optionArgs] = restArgs;
1106
- if (!name || !endpoint) {
1107
- throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
1108
- }
1109
- const token = parseRemoteTokenOption(optionArgs);
1110
- const { host, port } = parseRemoteEndpoint(endpoint);
1111
- const remote = await addRemoteDestination({
1112
- id: normalizeDestinationId(name),
1113
- host,
1114
- port,
1115
- token,
1116
- });
1117
- process.stdout.write(
1118
- `Remote destination "${remote.id}" saved (${remote.host}:${remote.port}).\n`
1119
- );
1120
- return;
1121
- }
1122
-
1123
- if (subcommand === 'remove') {
1124
- const [name] = restArgs;
1125
- if (!name) {
1126
- throw new Error('Usage: bbx remote remove <name>');
1127
- }
1128
- const removed = await removeRemoteDestination(name);
1129
- process.stdout.write(
1130
- removed
1131
- ? `Remote destination "${name}" removed.\n`
1132
- : `Remote destination "${name}" was not configured.\n`
1133
- );
1134
- return;
1135
- }
1136
-
1137
- if (subcommand === 'list') {
1138
- const config = await readRemoteConfig();
1139
- if (config.remotes.length === 0) {
1140
- process.stdout.write('No remote destinations configured.\n');
1141
- return;
1142
- }
1143
- process.stdout.write(
1144
- `${config.remotes.map((remote) => `${remote.id}\t${remote.host}:${remote.port}`).join('\n')}\n`
1145
- );
1146
- return;
1147
- }
1148
-
1149
- if (subcommand === 'test') {
1150
- const [name] = restArgs;
1151
- if (!name) {
1152
- throw new Error('Usage: bbx remote test <name>');
1153
- }
1154
- const remoteClient = await createBridgeClientForDestination(name);
1155
- try {
1156
- await remoteClient.connect();
1157
- const response = await remoteClient.request({ method: 'health.ping' });
1158
- if (response.ok) {
1159
- const health = /** @type {{ extensionConnected?: boolean }} */ (response.result);
1160
- process.stdout.write(
1161
- `Remote destination "${name}" is reachable (extension ${
1162
- health.extensionConnected === true ? 'connected' : 'not connected'
1163
- }).\n`
1164
- );
1165
- } else {
1166
- process.exitCode = 1;
1167
- process.stdout.write(`Remote destination "${name}" failed: ${response.error.message}\n`);
1168
- }
1169
- } catch (error) {
1170
- process.exitCode = 1;
1171
- process.stdout.write(
1172
- `Remote destination "${name}" is not reachable: ${
1173
- error instanceof Error ? error.message : String(error)
1174
- }\n`
1175
- );
1176
- } finally {
1177
- await remoteClient.close();
1178
- }
1179
- return;
1180
- }
1181
-
1182
- if (subcommand === 'destinations') {
1183
- const destinations = await listBridgeDestinations();
1184
- process.stdout.write(
1185
- `${destinations
1186
- .map((destination) =>
1187
- destination.local
1188
- ? `${destination.id}\tlocal`
1189
- : `${destination.id}\t${destination.host}:${destination.port}`
1190
- )
1191
- .join('\n')}\n`
1192
- );
1193
- return;
1194
- }
1195
-
1196
- throw new Error('Usage: bbx remote <add|remove|list|test|destinations>');
1197
- }
1198
-
1199
- /**
1200
- * @param {string[]} args
1201
- * @returns {string}
1202
- */
1203
- function parseRemoteTokenOption(args) {
1204
- let token = '';
1205
- for (let index = 0; index < args.length; index += 1) {
1206
- const arg = args[index];
1207
- if (arg === '--token') {
1208
- token = args[++index] || '';
1209
- continue;
1210
- }
1211
- throw new Error(`Unknown remote add option "${arg}".`);
1212
- }
1213
- if (!token.trim()) {
1214
- throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
1215
- }
1216
- return token.trim();
1217
- }
1218
-
1219
589
  /**
1220
590
  * @param {unknown} error
1221
591
  * @returns {string}
@@ -1242,216 +612,3 @@ function getClientTimeoutOverride() {
1242
612
  const value = Number.parseInt(raw, 10);
1243
613
  return Number.isFinite(value) && value > 0 ? value : undefined;
1244
614
  }
1245
-
1246
- /**
1247
- * Allow CLI tests to provide deterministic MCP client detection without relying
1248
- * on whatever tools happen to be installed on the host machine.
1249
- *
1250
- * @returns {{
1251
- * mcpDetectors?: Record<string, () => boolean>,
1252
- * }}
1253
- */
1254
- function getSetupStatusTestOverrides() {
1255
- if (!(TEST_DETECTED_MCP_CLIENTS_ENV in process.env)) {
1256
- return {};
1257
- }
1258
-
1259
- const detectedClients = new Set(
1260
- (process.env[TEST_DETECTED_MCP_CLIENTS_ENV] || '')
1261
- .split(',')
1262
- .map((value) => value.trim().toLowerCase())
1263
- .filter(Boolean)
1264
- );
1265
-
1266
- return {
1267
- mcpDetectors: Object.fromEntries(
1268
- MCP_CLIENT_NAMES.map((clientName) => [clientName, () => detectedClients.has(clientName)])
1269
- ),
1270
- };
1271
- }
1272
-
1273
- /**
1274
- * @param {{ detected: boolean, installed: boolean }} options
1275
- * @returns {string | undefined}
1276
- */
1277
- function formatSelectionHint(options) {
1278
- /** @type {string[]} */
1279
- const parts = [];
1280
- if (options.detected) {
1281
- parts.push('● detected');
1282
- }
1283
- if (options.installed) {
1284
- parts.push('installed');
1285
- }
1286
- return parts.length > 0 ? parts.join(' · ') : undefined;
1287
- }
1288
-
1289
- /**
1290
- * @returns {Promise<void>}
1291
- */
1292
- async function ensureClientConnection() {
1293
- if (!client.connected) {
1294
- await client.connect();
1295
- }
1296
- }
1297
-
1298
- /**
1299
- * @param {import('../../protocol/src/types.js').BridgeResponse} response
1300
- * @param {string} [method] - Optional method name for disambiguation
1301
- * @returns {Promise<void>}
1302
- */
1303
- async function printSummary(response, method) {
1304
- if (!response.ok) {
1305
- process.exitCode = 1;
1306
- }
1307
- printJson(annotateBridgeSummary(summarizeBridgeResponse(response, method), response));
1308
- }
1309
-
1310
- /**
1311
- * @param {unknown} value
1312
- * @returns {void}
1313
- */
1314
- function printJson(value) {
1315
- process.stdout.write(
1316
- `${JSON.stringify(sanitizeOutput(value), null, process.stdout.isTTY ? 2 : undefined)}\n`
1317
- );
1318
- }
1319
-
1320
- /**
1321
- * @param {import('../../protocol/src/types.js').BridgeResponse} response
1322
- * @returns {void}
1323
- */
1324
- function printCallResponse(response) {
1325
- if (response.ok) {
1326
- printJson(response.result);
1327
- return;
1328
- }
1329
-
1330
- process.exitCode = 1;
1331
- const errorText = `${response.error.code}: ${response.error.message}`;
1332
- process.stderr.write(
1333
- `${process.stderr.isTTY ? `\u001b[31m${sanitizeOutput(errorText)}\u001b[0m` : sanitizeOutput(errorText)}\n`
1334
- );
1335
- printJson(response);
1336
- }
1337
-
1338
- function printUsage() {
1339
- const blocks = ['Usage: bbx [--remote <name>] <command> [args]'];
1340
- for (const section of CLI_HELP_SECTIONS) {
1341
- blocks.push('', `${section.title}:`);
1342
- blocks.push(...section.lines.map((line) => ` ${line}`));
1343
- }
1344
- process.stdout.write(`${blocks.join('\n')}\n`);
1345
- }
1346
-
1347
- /**
1348
- * @returns {Promise<void>}
1349
- */
1350
- async function uninstallBrowserBridge() {
1351
- const cwd = process.cwd();
1352
- const globalProjectPath = os.homedir();
1353
-
1354
- for (const clientName of MCP_CLIENT_NAMES) {
1355
- await removeMcpConfig(clientName, { global: true, cwd });
1356
- }
1357
-
1358
- const removedGlobalSkillPaths = await removeAgentFiles({
1359
- targets: SUPPORTED_TARGETS,
1360
- projectPath: globalProjectPath,
1361
- global: true,
1362
- });
1363
- for (const removedPath of removedGlobalSkillPaths) {
1364
- process.stdout.write(`Removed ${removedPath}\n`);
1365
- }
1366
-
1367
- for (const clientName of MCP_CLIENT_NAMES) {
1368
- await removeMcpConfig(clientName, { global: false, cwd });
1369
- }
1370
-
1371
- const removedLocalSkillPaths = await removeAgentFiles({
1372
- targets: SUPPORTED_TARGETS,
1373
- projectPath: cwd,
1374
- global: false,
1375
- });
1376
- for (const removedPath of removedLocalSkillPaths) {
1377
- process.stdout.write(`Removed ${removedPath}\n`);
1378
- }
1379
-
1380
- for (const [index, browser] of SUPPORTED_BROWSERS.entries()) {
1381
- await uninstallNativeManifest({
1382
- browser,
1383
- removeBridgeDir: index === SUPPORTED_BROWSERS.length - 1,
1384
- });
1385
- }
1386
- }
1387
-
1388
- /**
1389
- * @param {string[]} args
1390
- * @returns {Promise<{ tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
1391
- */
1392
- async function parseCallCommand(args) {
1393
- const parsed = extractTabFlag(args);
1394
- const [first, second, ...extra] = parsed.rest;
1395
- if (!first) {
1396
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1397
- }
1398
- if (extra.length > 0) {
1399
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1400
- }
1401
-
1402
- if (first.includes('.')) {
1403
- const method = /** @type {BridgeMethod} */ (first);
1404
- if (!METHODS.includes(method)) {
1405
- throw new Error(`Unknown method "${first}". Run bbx skill to see available methods.`);
1406
- }
1407
- let rawParams = second;
1408
- // Support piped stdin: `echo '{"key":"val"}' | bbx call method -`
1409
- if (rawParams === '-') {
1410
- rawParams = await readStdin();
1411
- }
1412
- return {
1413
- method,
1414
- tabId: methodNeedsTab(method) ? parsed.tabId : null,
1415
- params: parseJsonObject(rawParams),
1416
- };
1417
- }
1418
-
1419
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1420
- }
1421
-
1422
- /**
1423
- * Extract the global --remote flag from anywhere in the argument list.
1424
- * Exits with a usage error when the flag is present without a value.
1425
- *
1426
- * @param {string[]} args
1427
- * @returns {{ remoteId: string | null, explicit: boolean, rest: string[] }}
1428
- */
1429
- function extractRemoteFlag(args) {
1430
- const rest = [...args];
1431
- const index = rest.indexOf('--remote');
1432
- if (index === -1) {
1433
- return { remoteId: null, explicit: false, rest };
1434
- }
1435
- const remoteId = rest[index + 1];
1436
- if (!remoteId || remoteId.startsWith('--')) {
1437
- process.stderr.write('--remote requires a destination name (see `bbx remote list`).\n');
1438
- process.exit(1);
1439
- }
1440
- rest.splice(index, 2);
1441
- return { remoteId, explicit: true, rest };
1442
- }
1443
-
1444
- /**
1445
- * @param {string[]} args
1446
- * @returns {{ tabId: number | null, rest: string[] }}
1447
- */
1448
- function extractTabFlag(args) {
1449
- const rest = [...args];
1450
- let tabId = null;
1451
- const tabIndex = rest.indexOf('--tab');
1452
- if (tabIndex !== -1) {
1453
- tabId = parseIntArg(rest[tabIndex + 1], 'tabId');
1454
- rest.splice(tabIndex, 2);
1455
- }
1456
- return { tabId, rest };
1457
- }