@browserbridge/bbx 1.7.5 → 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.
@@ -4,82 +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 { getBridgeAuthTokenPath } 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';
21
- import {
22
- createRuntimeContext,
23
- getProtocolVersion,
24
- METHODS,
25
- summarizeBatchErrorItem,
26
- summarizeBatchResponseItem,
27
- } from '../../protocol/src/index.js';
11
+ import { createRuntimeContext, METHODS } from '../../protocol/src/index.js';
28
12
  import {
29
13
  restartRegisteredMcpProcesses,
30
14
  tryStartMcpProcessControl,
31
15
  } from '../../mcp-server/src/lifecycle.js';
32
16
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
33
- import { CLI_HELP_SECTIONS, SHORTCUT_COMMANDS } from './command-registry.js';
34
- import { atomicWriteFile } from './atomic-write.js';
35
- import {
36
- interactiveCheckbox,
37
- interactiveConfirm,
38
- methodNeedsTab,
39
- parseIntArg,
40
- parseJsonObject,
41
- sanitizeOutput,
42
- } from './cli-helpers.js';
43
17
  import {
44
- findInstalledManagedTargets,
45
- installAgentFiles,
46
- installMcpClientSetup,
47
- parseInstallAgentArgs,
48
- removeAgentFiles,
49
- SUPPORTED_TARGETS,
50
- TARGET_LABELS,
51
- } 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';
52
28
  import {
53
- findConfiguredMcpClients,
54
- formatMcpConfig,
55
- isMcpClientName,
56
- MCP_CLIENT_LABELS,
57
- MCP_CLIENT_NAMES,
58
- removeMcpConfig,
59
- } 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';
60
35
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
61
- import {
62
- addRemoteDestination,
63
- assertProxyBindSafety,
64
- createBridgeClientForDestination,
65
- listBridgeDestinations,
66
- normalizeDestinationId,
67
- parseRemoteEndpoint,
68
- readRemoteConfig,
69
- removeRemoteDestination,
70
- resolveProxyEnableSettings,
71
- isLoopbackHost,
72
- } from './remotes.js';
73
- import { collectSetupStatus } from './setup-status.js';
74
- import { annotateBridgeSummary, summarizeBridgeResponse } from './subagent.js';
36
+ import { createBridgeClientForDestination } from './remotes.js';
75
37
 
76
38
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
77
39
  /** @typedef {import('./types.js').ScreenshotResult} ScreenshotResult */
78
40
 
79
41
  const REQUEST_SOURCE = 'cli';
80
42
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
81
- const TEST_DETECTED_MCP_CLIENTS_ENV = 'BBX_TEST_DETECTED_MCP_CLIENTS';
82
- const TEST_DETECTED_SKILL_TARGETS_ENV = 'BBX_TEST_DETECTED_SKILL_TARGETS';
83
43
  const REMOTE_ENV = 'BBX_REMOTE';
84
44
 
85
45
  /**
@@ -107,25 +67,6 @@ const LOCAL_ONLY_COMMANDS = new Set([
107
67
  'restart',
108
68
  ]);
109
69
 
110
- /**
111
- * Read all of stdin as UTF-8 text. Resolves once stdin closes.
112
- *
113
- * @returns {Promise<string>}
114
- */
115
- function readStdin() {
116
- return new Promise((resolve, reject) => {
117
- const chunks = /** @type {Buffer[]} */ ([]);
118
- process.stdin.setEncoding('utf8');
119
- process.stdin.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
120
- process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim()));
121
- process.stdin.on('error', reject);
122
- // If stdin is a TTY and nothing is piped, read nothing
123
- if (process.stdin.isTTY) {
124
- resolve('');
125
- }
126
- });
127
- }
128
-
129
70
  const remoteFlag = extractRemoteFlag(process.argv.slice(2));
130
71
  const [command, ...rest] = remoteFlag.rest;
131
72
 
@@ -163,7 +104,6 @@ if (command === 'skill') {
163
104
 
164
105
  if (command === 'install') {
165
106
  const { execFileSync } = await import('node:child_process');
166
- const { fileURLToPath } = await import('node:url');
167
107
  const installScript = path.resolve(
168
108
  path.dirname(fileURLToPath(import.meta.url)),
169
109
  '../../native-host/bin/install-manifest.js'
@@ -185,242 +125,11 @@ if (command === 'uninstall') {
185
125
  }
186
126
 
187
127
  if (command === 'install-skill') {
188
- // When no positional target is given, detect installed agents and prompt.
189
- if (!hasExplicitInstallSkillTarget(rest)) {
190
- let scopeOptions;
191
- try {
192
- scopeOptions = parseInstallAgentArgs(rest);
193
- } catch (error) {
194
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
195
- process.exit(1);
196
- }
197
-
198
- const isGlobal = scopeOptions.global !== false;
199
- const projectPath = isGlobal ? os.homedir() : scopeOptions.projectPath;
200
-
201
- const setupStatus = await collectSetupStatus({
202
- global: isGlobal,
203
- cwd: process.cwd(),
204
- projectPath,
205
- ...getSetupStatusTestOverrides(),
206
- });
207
- /** @type {import('./types.js').SupportedTarget[]} */
208
- const detected = /** @type {import('./types.js').SupportedTarget[]} */ (
209
- setupStatus.skillTargets.filter((entry) => entry.detected).map((entry) => entry.key)
210
- );
211
- const installedManagedTargets = new Set(
212
- setupStatus.skillTargets
213
- .filter((entry) => entry.installed && entry.managed)
214
- .map((entry) => entry.key)
215
- );
216
- const installedManagedTargetList =
217
- /** @type {import('./types.js').SupportedTarget[]} */ ([...installedManagedTargets]);
218
-
219
- // Aliases like 'openai' and 'google' map to canonical targets and stay omitted.
220
- const items = SUPPORTED_TARGETS.map((t) => ({
221
- value: t,
222
- label: `${t.padEnd(10)} ${TARGET_LABELS[t]}`,
223
- hint: formatSelectionHint({
224
- detected: detected.includes(t),
225
- installed: installedManagedTargets.has(t),
226
- }),
227
- checked: installedManagedTargets.has(t),
228
- }));
229
-
230
- const selected = await interactiveCheckbox(
231
- 'Select agents to install skill for (↑↓ move · space toggle · a all · enter confirm)',
232
- items
233
- );
234
-
235
- /** @type {import('./types.js').SupportedTarget[]} */
236
- let targets;
237
- if (selected === null) {
238
- // Non-TTY: prefer managed installs, then detected targets (always includes 'agents').
239
- targets = installedManagedTargets.size > 0 ? installedManagedTargetList : detected;
240
- } else {
241
- targets = /** @type {import('./types.js').SupportedTarget[]} */ (selected);
242
- }
243
-
244
- if (selected !== null) {
245
- const deselectedTargets =
246
- /** @type {import('./types.js').SupportedTarget[]} */ (
247
- installedManagedTargetList.filter((target) => !targets.includes(target))
248
- );
249
- const removableTargets = await findInstalledManagedTargets({
250
- targets: deselectedTargets,
251
- projectPath,
252
- global: isGlobal,
253
- });
254
- if (removableTargets.length > 0) {
255
- const confirmed = await interactiveConfirm(
256
- `Remove Browser Bridge skill from deselected targets: ${removableTargets.join(', ')}?`
257
- );
258
- if (confirmed) {
259
- const removedPaths = await removeAgentFiles({
260
- targets: removableTargets,
261
- projectPath,
262
- global: isGlobal,
263
- });
264
- for (const p of removedPaths) process.stdout.write(`Removed ${p}\n`);
265
- }
266
- }
267
- }
268
-
269
- if (targets.length === 0) {
270
- process.stdout.write('No targets selected.\n');
271
- process.exit(0);
272
- }
273
-
274
- const installedPaths = await installAgentFiles({
275
- targets,
276
- projectPath,
277
- global: isGlobal,
278
- });
279
- for (const p of installedPaths) process.stdout.write(`Installed ${p}\n`);
280
- process.exit(0);
281
- }
282
-
283
- // Explicit targets or 'all' provided - use existing arg-parsing logic.
284
- const options = parseInstallAgentArgs(rest);
285
- const installedPaths = await installAgentFiles(options);
286
- for (const installedPath of installedPaths) {
287
- process.stdout.write(`Installed ${installedPath}\n`);
288
- }
289
- process.exit(0);
128
+ await runInstallSkillCommand(rest);
290
129
  }
291
130
 
292
131
  if (command === 'install-mcp') {
293
- let isGlobal = true;
294
- /** @type {string[]} */
295
- const positionals = [];
296
-
297
- for (const arg of rest) {
298
- if (arg === '--local') {
299
- isGlobal = false;
300
- continue;
301
- }
302
- if (arg === '--global') {
303
- isGlobal = true;
304
- continue;
305
- }
306
- if (arg.startsWith('--')) {
307
- process.stderr.write(`Unknown install-mcp option "${arg}".\n`);
308
- process.exit(1);
309
- }
310
- positionals.push(arg);
311
- }
312
-
313
- if (positionals.length > 1) {
314
- process.stderr.write(`Unexpected extra argument "${positionals[1]}".\n`);
315
- process.exit(1);
316
- }
317
-
318
- const clientArg = positionals[0];
319
-
320
- /** @type {import('./types.js').McpClientName[]} */
321
- let clients;
322
-
323
- if (!clientArg) {
324
- // No client specified: inspect current MCP config and prompt interactively.
325
- const setupStatus = await collectSetupStatus({
326
- global: isGlobal,
327
- cwd: process.cwd(),
328
- projectPath: process.cwd(),
329
- ...getSetupStatusTestOverrides(),
330
- });
331
- const detected = /** @type {import('./types.js').McpClientName[]} */ (
332
- setupStatus.mcpClients.filter((entry) => entry.detected).map((entry) => entry.key)
333
- );
334
- const configuredClients = new Set(
335
- setupStatus.mcpClients.filter((entry) => entry.configured).map((entry) => entry.key)
336
- );
337
- const configuredClientList =
338
- /** @type {import('./types.js').McpClientName[]} */ ([...configuredClients]);
339
- const items = MCP_CLIENT_NAMES.map((c) => ({
340
- value: c,
341
- label: `${c.padEnd(10)} ${MCP_CLIENT_LABELS[c]}`,
342
- hint: formatSelectionHint({
343
- detected: detected.includes(c),
344
- installed: configuredClients.has(c),
345
- }),
346
- checked: configuredClients.has(c),
347
- }));
348
-
349
- const selected = await interactiveCheckbox(
350
- 'Select clients to configure (↑↓ move · space toggle · a all · enter confirm)',
351
- items
352
- );
353
-
354
- if (selected === null) {
355
- // Non-TTY: prefer configured clients, then detected clients, then all.
356
- clients =
357
- configuredClients.size > 0
358
- ? configuredClientList
359
- : detected.length > 0
360
- ? detected
361
- : [...MCP_CLIENT_NAMES];
362
- } else {
363
- clients = /** @type {import('./types.js').McpClientName[]} */ (selected);
364
- }
365
-
366
- if (selected !== null) {
367
- const deselectedClients =
368
- /** @type {import('./types.js').McpClientName[]} */ (
369
- configuredClientList.filter((clientName) => !clients.includes(clientName))
370
- );
371
- const removableClients = await findConfiguredMcpClients({
372
- clients: deselectedClients,
373
- global: isGlobal,
374
- cwd: process.cwd(),
375
- });
376
- if (removableClients.length > 0) {
377
- const confirmed = await interactiveConfirm(
378
- `Remove Browser Bridge MCP config from deselected clients: ${removableClients.join(', ')}?`
379
- );
380
- if (confirmed) {
381
- for (const clientName of removableClients) {
382
- await removeMcpConfig(clientName, {
383
- global: isGlobal,
384
- cwd: process.cwd(),
385
- });
386
- }
387
- }
388
- }
389
- }
390
-
391
- if (clients.length === 0) {
392
- process.stdout.write('No clients selected.\n');
393
- process.exit(0);
394
- }
395
- } else if (clientArg === 'all') {
396
- clients = [...MCP_CLIENT_NAMES];
397
- } else {
398
- const parts = clientArg
399
- .split(',')
400
- .map((s) => s.trim().toLowerCase())
401
- .filter(Boolean);
402
- if (parts.includes('all')) {
403
- clients = [...MCP_CLIENT_NAMES];
404
- } else {
405
- clients = [];
406
- for (const part of parts) {
407
- if (!isMcpClientName(part)) {
408
- process.stderr.write(
409
- `Unknown client "${part}". Supported: ${MCP_CLIENT_NAMES.join(', ')}, all\n`
410
- );
411
- process.exit(1);
412
- }
413
- clients.push(part);
414
- }
415
- }
416
- }
417
-
418
- await installMcpClientSetup(clients, {
419
- global: isGlobal,
420
- projectPath: process.cwd(),
421
- stdout: process.stdout,
422
- });
423
- process.exit(0);
132
+ await runInstallMcpCommand(rest);
424
133
  }
425
134
 
426
135
  if (command === 'mcp') {
@@ -633,130 +342,7 @@ async function main() {
633
342
  }
634
343
 
635
344
  if (command === 'batch') {
636
- const input = rest[0];
637
- if (!input) {
638
- throw new Error('Usage: batch \'[{"method":"...","params":{...}}, ...]\'');
639
- }
640
- let calls;
641
- try {
642
- calls = JSON.parse(input);
643
- } catch {
644
- throw new Error('Invalid JSON syntax. Expected a JSON array of bridge calls.');
645
- }
646
- if (!Array.isArray(calls)) {
647
- throw new Error('Batch input must be a JSON array.');
648
- }
649
- await ensureClientConnection();
650
- const results = await Promise.all(
651
- calls.map(async (call) => {
652
- if (!call || typeof call !== 'object' || Array.isArray(call)) {
653
- return {
654
- method: '',
655
- tabId: null,
656
- ok: false,
657
- summary: 'INVALID_REQUEST: Each batch call needs a method.',
658
- evidence: null,
659
- durationMs: 0,
660
- approxTokens: 0,
661
- meta: { protocol_version: getProtocolVersion() },
662
- error: {
663
- code: 'INVALID_REQUEST',
664
- message: 'Each batch call needs a method.',
665
- },
666
- response: null,
667
- };
668
- }
669
- const batchCall = /** @type {Record<string, unknown>} */ (call);
670
- if (typeof batchCall.method !== 'string') {
671
- return {
672
- method: '',
673
- tabId: null,
674
- ok: false,
675
- summary: 'INVALID_REQUEST: Each batch call needs a method.',
676
- evidence: null,
677
- durationMs: 0,
678
- approxTokens: 0,
679
- meta: { protocol_version: getProtocolVersion() },
680
- error: {
681
- code: 'INVALID_REQUEST',
682
- message: 'Each batch call needs a method.',
683
- },
684
- response: null,
685
- };
686
- }
687
- if (!METHODS.includes(/** @type {BridgeMethod} */ (batchCall.method))) {
688
- return {
689
- method: batchCall.method,
690
- tabId: null,
691
- ok: false,
692
- summary: `INVALID_REQUEST: Unknown bridge method "${batchCall.method}".`,
693
- evidence: null,
694
- durationMs: 0,
695
- approxTokens: 0,
696
- meta: { protocol_version: getProtocolVersion() },
697
- error: {
698
- code: 'INVALID_REQUEST',
699
- message: `Unknown bridge method "${batchCall.method}".`,
700
- },
701
- response: null,
702
- };
703
- }
704
- const method = /** @type {BridgeMethod} */ (batchCall.method);
705
- if (
706
- batchCall.params !== undefined &&
707
- (!batchCall.params ||
708
- typeof batchCall.params !== 'object' ||
709
- Array.isArray(batchCall.params))
710
- ) {
711
- return {
712
- method,
713
- tabId: null,
714
- ok: false,
715
- summary: 'INVALID_REQUEST: Batch call params must be a JSON object.',
716
- evidence: null,
717
- durationMs: 0,
718
- approxTokens: 0,
719
- meta: { protocol_version: getProtocolVersion() },
720
- error: {
721
- code: 'INVALID_REQUEST',
722
- message: 'Batch call params must be a JSON object.',
723
- },
724
- response: null,
725
- };
726
- }
727
- const params =
728
- batchCall.params === undefined
729
- ? {}
730
- : /** @type {Record<string, unknown>} */ (batchCall.params);
731
- const tabId =
732
- methodNeedsTab(method) &&
733
- typeof batchCall.tabId === 'number' &&
734
- Number.isInteger(batchCall.tabId) &&
735
- batchCall.tabId > 0
736
- ? batchCall.tabId
737
- : null;
738
- const startTime = Date.now();
739
- try {
740
- const response = await requestBridge(client, method, params, {
741
- tabId,
742
- source: REQUEST_SOURCE,
743
- });
744
- return summarizeBatchResponseItem({
745
- method,
746
- tabId,
747
- response,
748
- durationMs: Date.now() - startTime,
749
- });
750
- } catch (err) {
751
- return summarizeBatchErrorItem({
752
- method,
753
- tabId,
754
- error: err,
755
- durationMs: Date.now() - startTime,
756
- });
757
- }
758
- })
759
- );
345
+ const results = await runBatchCalls(client, rest[0], REQUEST_SOURCE);
760
346
  if (results.some((result) => !result.ok)) {
761
347
  process.exitCode = 1;
762
348
  }
@@ -1000,408 +586,6 @@ async function main() {
1000
586
  }
1001
587
  }
1002
588
 
1003
- /**
1004
- * @param {string[]} args
1005
- * @returns {Promise<void>}
1006
- */
1007
- async function handleProxyCommand(args) {
1008
- const [subcommand, ...restArgs] = args;
1009
- if (subcommand === 'enable') {
1010
- const options = parseProxyEnableArgs(restArgs);
1011
- const existing = readProxyConfig();
1012
- const { port, bindHost, token, tokenSource } = resolveProxyEnableSettings(
1013
- existing,
1014
- options,
1015
- randomUUID
1016
- );
1017
- assertProxyBindSafety(existing, options, bindHost);
1018
- await atomicWriteFile(getBridgeAuthTokenPath(), `${token}\n`, {
1019
- encoding: 'utf8',
1020
- mode: 0o600,
1021
- });
1022
- const configPath = getProxyConfigPath();
1023
- await atomicWriteFile(
1024
- configPath,
1025
- `${JSON.stringify(
1026
- {
1027
- enabled: true,
1028
- port,
1029
- bindHost,
1030
- token,
1031
- },
1032
- null,
1033
- 2
1034
- )}\n`,
1035
- { encoding: 'utf8', mode: 0o600 }
1036
- );
1037
- const result = await restartBridgeDaemon();
1038
- const tokenNote =
1039
- tokenSource === 'existing'
1040
- ? ' (unchanged - already-configured clients keep working; pass --rotate-token to generate a new secret)'
1041
- : tokenSource === 'generated' && existing
1042
- ? ' (rotated - update every configured client with `bbx remote add`)'
1043
- : '';
1044
- const lines = [
1045
- `Browser Bridge proxy enabled on ${bindHost}:${port}.`,
1046
- '',
1047
- `Token: ${token}${tokenNote}`,
1048
- `Config: ${configPath}`,
1049
- `Daemon: ${result.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
1050
- ];
1051
- if (tokenSource !== 'existing') {
1052
- if (isLoopbackHost(bindHost)) {
1053
- lines.push(
1054
- '',
1055
- 'On your dev machine, open an SSH local-forward:',
1056
- '',
1057
- `ssh -N -L ${port}:127.0.0.1:${port} <user>@<browser-host>`,
1058
- '',
1059
- 'Save the token to a private file, then add the tunneled remote:',
1060
- '',
1061
- `bbx remote add remote-bbx 127.0.0.1:${port} --token-file <token-file>`
1062
- );
1063
- } else {
1064
- lines.push(
1065
- '',
1066
- 'WARNING: raw TCP is exposed without transport encryption.',
1067
- 'On your dev machine, add this direct remote:',
1068
- '',
1069
- `bbx remote add remote-bbx ${getProxyExampleHost(bindHost)}:${port} --token-file <token-file>`
1070
- );
1071
- }
1072
- }
1073
- lines.push('');
1074
- process.stdout.write(lines.join('\n'));
1075
- return;
1076
- }
1077
-
1078
- if (subcommand === 'disable') {
1079
- const configPath = getProxyConfigPath();
1080
- await fs.promises.rm(configPath, { force: true });
1081
- const result = await restartBridgeDaemon();
1082
- process.stdout.write(
1083
- `Browser Bridge proxy disabled. Daemon ${result.previouslyRunning ? 'restarted' : 'started'}.\n`
1084
- );
1085
- return;
1086
- }
1087
-
1088
- if (subcommand === 'status') {
1089
- const config = readProxyConfig();
1090
- if (!config) {
1091
- process.stdout.write('Browser Bridge proxy is disabled.\n');
1092
- return;
1093
- }
1094
- const reachable = await pingExistingDaemon(createTcpBridgeTransport(config.port, '127.0.0.1'));
1095
- process.stdout.write(
1096
- [
1097
- `Browser Bridge proxy is enabled on ${config.bindHost}:${config.port}.`,
1098
- `Config: ${getProxyConfigPath()}`,
1099
- `Daemon: ${reachable ? 'reachable' : 'not reachable'} on 127.0.0.1:${config.port}`,
1100
- '',
1101
- ].join('\n')
1102
- );
1103
- return;
1104
- }
1105
-
1106
- throw new Error('Usage: bbx proxy <enable|disable|status>');
1107
- }
1108
-
1109
- /**
1110
- * Parse `bbx proxy enable` flags. Values stay undefined when not passed so an
1111
- * existing proxy config can be reused instead of silently reset to defaults.
1112
- *
1113
- * @param {string[]} args
1114
- * @returns {import('./remotes.js').ProxyEnableOptions}
1115
- */
1116
- function parseProxyEnableArgs(args) {
1117
- /** @type {import('./remotes.js').ProxyEnableOptions} */
1118
- const options = {};
1119
- for (let index = 0; index < args.length; index += 1) {
1120
- const arg = args[index];
1121
- if (arg === '--port') {
1122
- options.port = parseIntArg(args[++index] || '', 'port');
1123
- continue;
1124
- }
1125
- if (arg === '--bind-host') {
1126
- options.bindHost = args[++index] || '';
1127
- if (!options.bindHost.trim()) {
1128
- throw new Error('--bind-host requires a value.');
1129
- }
1130
- continue;
1131
- }
1132
- if (arg === '--token') {
1133
- options.token = args[++index] || '';
1134
- if (!options.token.trim()) {
1135
- throw new Error('--token requires a value.');
1136
- }
1137
- continue;
1138
- }
1139
- if (arg === '--rotate-token') {
1140
- options.rotateToken = true;
1141
- continue;
1142
- }
1143
- if (arg === '--unsafe-plaintext') {
1144
- options.unsafePlaintext = true;
1145
- continue;
1146
- }
1147
- throw new Error(`Unknown proxy enable option "${arg}".`);
1148
- }
1149
- if (options.token && options.rotateToken) {
1150
- throw new Error('Use either --token or --rotate-token, not both.');
1151
- }
1152
- if (options.port !== undefined && (options.port < 1 || options.port > 65535)) {
1153
- throw new Error('port must be an integer between 1 and 65535.');
1154
- }
1155
- return options;
1156
- }
1157
-
1158
- /**
1159
- * @param {string} bindHost
1160
- * @returns {string}
1161
- */
1162
- function getProxyExampleHost(bindHost) {
1163
- if (bindHost !== '0.0.0.0' && bindHost !== '::') {
1164
- return bindHost;
1165
- }
1166
-
1167
- /** @type {string | null} */
1168
- let firstIpv4 = null;
1169
- for (const [name, entries] of Object.entries(os.networkInterfaces())) {
1170
- for (const entry of entries ?? []) {
1171
- if (!entry.internal && entry.family === 'IPv4') {
1172
- if (name.toLowerCase() === 'ethernet 0') {
1173
- return entry.address;
1174
- }
1175
- firstIpv4 ??= entry.address;
1176
- }
1177
- }
1178
- }
1179
-
1180
- return firstIpv4 ?? '<host>';
1181
- }
1182
-
1183
- /**
1184
- * @param {string[]} args
1185
- * @returns {Promise<void>}
1186
- */
1187
- async function handleRemoteCommand(args) {
1188
- const [subcommand, ...restArgs] = args;
1189
- if (subcommand === 'add') {
1190
- const [name, endpoint, ...optionArgs] = restArgs;
1191
- if (!name || !endpoint) {
1192
- throw new Error(
1193
- 'Usage: bbx remote add <name> <host:port> (--token <token>|--token-file <path>)'
1194
- );
1195
- }
1196
- const token = await parseRemoteTokenOption(optionArgs);
1197
- const { host, port } = parseRemoteEndpoint(endpoint);
1198
- const remote = await addRemoteDestination({
1199
- id: normalizeDestinationId(name),
1200
- host,
1201
- port,
1202
- token,
1203
- });
1204
- process.stdout.write(
1205
- `Remote destination "${remote.id}" saved (${remote.host}:${remote.port}).\n`
1206
- );
1207
- return;
1208
- }
1209
-
1210
- if (subcommand === 'remove') {
1211
- const [name] = restArgs;
1212
- if (!name) {
1213
- throw new Error('Usage: bbx remote remove <name>');
1214
- }
1215
- const removed = await removeRemoteDestination(name);
1216
- process.stdout.write(
1217
- removed
1218
- ? `Remote destination "${name}" removed.\n`
1219
- : `Remote destination "${name}" was not configured.\n`
1220
- );
1221
- return;
1222
- }
1223
-
1224
- if (subcommand === 'list') {
1225
- const config = await readRemoteConfig();
1226
- if (config.remotes.length === 0) {
1227
- process.stdout.write('No remote destinations configured.\n');
1228
- return;
1229
- }
1230
- process.stdout.write(
1231
- `${config.remotes.map((remote) => `${remote.id}\t${remote.host}:${remote.port}`).join('\n')}\n`
1232
- );
1233
- return;
1234
- }
1235
-
1236
- if (subcommand === 'test') {
1237
- const [name] = restArgs;
1238
- if (!name) {
1239
- throw new Error('Usage: bbx remote test <name>');
1240
- }
1241
- const remoteClient = await createBridgeClientForDestination(name);
1242
- try {
1243
- await remoteClient.connect();
1244
- const response = await remoteClient.request({ method: 'health.ping' });
1245
- if (response.ok) {
1246
- const health = /** @type {{ extensionConnected?: boolean }} */ (response.result);
1247
- process.stdout.write(
1248
- `Remote destination "${name}" is reachable (extension ${
1249
- health.extensionConnected === true ? 'connected' : 'not connected'
1250
- }).\n`
1251
- );
1252
- } else {
1253
- process.exitCode = 1;
1254
- process.stdout.write(`Remote destination "${name}" failed: ${response.error.message}\n`);
1255
- }
1256
- } catch (error) {
1257
- process.exitCode = 1;
1258
- process.stdout.write(
1259
- `Remote destination "${name}" is not reachable: ${
1260
- error instanceof Error ? error.message : String(error)
1261
- }\n`
1262
- );
1263
- } finally {
1264
- await remoteClient.close();
1265
- }
1266
- return;
1267
- }
1268
-
1269
- if (subcommand === 'destinations') {
1270
- const destinations = await listBridgeDestinations();
1271
- process.stdout.write(
1272
- `${destinations
1273
- .map((destination) =>
1274
- destination.local
1275
- ? `${destination.id}\tlocal`
1276
- : `${destination.id}\t${destination.host}:${destination.port}`
1277
- )
1278
- .join('\n')}\n`
1279
- );
1280
- return;
1281
- }
1282
-
1283
- throw new Error('Usage: bbx remote <add|remove|list|test|destinations>');
1284
- }
1285
-
1286
- /**
1287
- * @param {string[]} args
1288
- * @returns {Promise<string>}
1289
- */
1290
- async function parseRemoteTokenOption(args) {
1291
- let token = '';
1292
- let tokenFile = '';
1293
- for (let index = 0; index < args.length; index += 1) {
1294
- const arg = args[index];
1295
- if (arg === '--token') {
1296
- const value = args[++index];
1297
- if (!value || value.startsWith('--')) {
1298
- throw new Error('--token requires a value.');
1299
- }
1300
- token = value;
1301
- continue;
1302
- }
1303
- if (arg === '--token-file') {
1304
- const value = args[++index];
1305
- if (!value || value.startsWith('--')) {
1306
- throw new Error('--token-file requires a path.');
1307
- }
1308
- tokenFile = value;
1309
- continue;
1310
- }
1311
- throw new Error(`Unknown remote add option "${arg}".`);
1312
- }
1313
- if ((token && tokenFile) || (!token.trim() && !tokenFile.trim())) {
1314
- throw new Error(
1315
- 'Usage: bbx remote add <name> <host:port> (--token <token>|--token-file <path>)'
1316
- );
1317
- }
1318
- return tokenFile ? (await fs.promises.readFile(tokenFile, 'utf8')).trim() : token.trim();
1319
- }
1320
-
1321
- /**
1322
- * Determine whether install-skill includes an explicit target without treating
1323
- * option values such as `--project <path>` as positional targets.
1324
- *
1325
- * @param {string[]} args
1326
- * @returns {boolean}
1327
- */
1328
- function hasExplicitInstallSkillTarget(args) {
1329
- for (let index = 0; index < args.length; index += 1) {
1330
- const arg = args[index];
1331
- if (arg === '--project') {
1332
- index += 1;
1333
- continue;
1334
- }
1335
- if (arg === '--agents' || arg === '--agent') {
1336
- return true;
1337
- }
1338
- if (arg.startsWith('--agents=') || arg.startsWith('--agent=')) {
1339
- return true;
1340
- }
1341
- if (!arg.startsWith('--')) {
1342
- return true;
1343
- }
1344
- }
1345
- return false;
1346
- }
1347
-
1348
- /**
1349
- * @param {string[]} args
1350
- * @returns {{ pattern: string, isBlock: boolean, statusCode: number, body: string | undefined }}
1351
- */
1352
- function parseInterceptAddArgs(args) {
1353
- const [pattern, ...optionArgs] = args;
1354
- if (!pattern || pattern.startsWith('--')) {
1355
- throw new Error(
1356
- 'Usage: intercept add <urlPattern> [--respond <body>] [--status <code>] [--block]'
1357
- );
1358
- }
1359
-
1360
- let isBlock = false;
1361
- let statusCode = 200;
1362
- let hasStatus = false;
1363
- let body;
1364
- for (let index = 0; index < optionArgs.length; index += 1) {
1365
- const option = optionArgs[index];
1366
- if (option === '--block') {
1367
- if (isBlock) {
1368
- throw new Error('The --block option may only be specified once.');
1369
- }
1370
- isBlock = true;
1371
- continue;
1372
- }
1373
- if (option === '--respond') {
1374
- const value = optionArgs[++index];
1375
- if (body !== undefined) {
1376
- throw new Error('The --respond option may only be specified once.');
1377
- }
1378
- if (value === undefined || value.startsWith('--')) {
1379
- throw new Error('--respond requires a body value.');
1380
- }
1381
- body = value;
1382
- continue;
1383
- }
1384
- if (option === '--status') {
1385
- if (hasStatus) {
1386
- throw new Error('The --status option may only be specified once.');
1387
- }
1388
- const value = optionArgs[++index];
1389
- statusCode = parseIntArg(value, 'status');
1390
- if (statusCode < 100 || statusCode > 599) {
1391
- throw new Error('status must be an integer between 100 and 599.');
1392
- }
1393
- hasStatus = true;
1394
- continue;
1395
- }
1396
- throw new Error(`Unknown or extra intercept add option "${option}".`);
1397
- }
1398
-
1399
- if (isBlock && body !== undefined) {
1400
- throw new Error('Use either --block or --respond, not both.');
1401
- }
1402
- return { pattern, isBlock, statusCode, body };
1403
- }
1404
-
1405
589
  /**
1406
590
  * @param {unknown} error
1407
591
  * @returns {string}
@@ -1428,227 +612,3 @@ function getClientTimeoutOverride() {
1428
612
  const value = Number.parseInt(raw, 10);
1429
613
  return Number.isFinite(value) && value > 0 ? value : undefined;
1430
614
  }
1431
-
1432
- /**
1433
- * Allow CLI tests to provide deterministic MCP client detection without relying
1434
- * on whatever tools happen to be installed on the host machine.
1435
- *
1436
- * @returns {{
1437
- * mcpDetectors?: Record<string, () => boolean>,
1438
- * skillDetectors?: Record<string, () => boolean>,
1439
- * }}
1440
- */
1441
- function getSetupStatusTestOverrides() {
1442
- /** @type {{ mcpDetectors?: Record<string, () => boolean>, skillDetectors?: Record<string, () => boolean> }} */
1443
- const overrides = {};
1444
- if (TEST_DETECTED_MCP_CLIENTS_ENV in process.env) {
1445
- const detectedClients = new Set(
1446
- (process.env[TEST_DETECTED_MCP_CLIENTS_ENV] || '')
1447
- .split(',')
1448
- .map((value) => value.trim().toLowerCase())
1449
- .filter(Boolean)
1450
- );
1451
- overrides.mcpDetectors = Object.fromEntries(
1452
- MCP_CLIENT_NAMES.map((clientName) => [clientName, () => detectedClients.has(clientName)])
1453
- );
1454
- }
1455
-
1456
- if (TEST_DETECTED_SKILL_TARGETS_ENV in process.env) {
1457
- const detectedTargets = new Set(
1458
- (process.env[TEST_DETECTED_SKILL_TARGETS_ENV] || '')
1459
- .split(',')
1460
- .map((value) => value.trim().toLowerCase())
1461
- .filter(Boolean)
1462
- );
1463
- overrides.skillDetectors = Object.fromEntries(
1464
- SUPPORTED_TARGETS.map((target) => [target, () => detectedTargets.has(target)])
1465
- );
1466
- }
1467
- return overrides;
1468
- }
1469
-
1470
- /**
1471
- * @param {{ detected: boolean, installed: boolean }} options
1472
- * @returns {string | undefined}
1473
- */
1474
- function formatSelectionHint(options) {
1475
- /** @type {string[]} */
1476
- const parts = [];
1477
- if (options.detected) {
1478
- parts.push('● detected');
1479
- }
1480
- if (options.installed) {
1481
- parts.push('installed');
1482
- }
1483
- return parts.length > 0 ? parts.join(' · ') : undefined;
1484
- }
1485
-
1486
- /**
1487
- * @returns {Promise<void>}
1488
- */
1489
- async function ensureClientConnection() {
1490
- if (!client.connected) {
1491
- await client.connect();
1492
- }
1493
- }
1494
-
1495
- /**
1496
- * @param {import('../../protocol/src/types.js').BridgeResponse} response
1497
- * @param {string} [method] - Optional method name for disambiguation
1498
- * @returns {Promise<void>}
1499
- */
1500
- async function printSummary(response, method) {
1501
- if (!response.ok) {
1502
- process.exitCode = 1;
1503
- }
1504
- printJson(annotateBridgeSummary(summarizeBridgeResponse(response, method), response));
1505
- }
1506
-
1507
- /**
1508
- * @param {unknown} value
1509
- * @returns {void}
1510
- */
1511
- function printJson(value) {
1512
- process.stdout.write(
1513
- `${JSON.stringify(sanitizeOutput(value), null, process.stdout.isTTY ? 2 : undefined)}\n`
1514
- );
1515
- }
1516
-
1517
- /**
1518
- * @param {import('../../protocol/src/types.js').BridgeResponse} response
1519
- * @returns {void}
1520
- */
1521
- function printCallResponse(response) {
1522
- if (response.ok) {
1523
- printJson(response.result);
1524
- return;
1525
- }
1526
-
1527
- process.exitCode = 1;
1528
- const errorText = `${response.error.code}: ${response.error.message}`;
1529
- process.stderr.write(
1530
- `${process.stderr.isTTY ? `\u001b[31m${sanitizeOutput(errorText)}\u001b[0m` : sanitizeOutput(errorText)}\n`
1531
- );
1532
- printJson(response);
1533
- }
1534
-
1535
- function printUsage() {
1536
- const blocks = ['Usage: bbx [--remote <name>] <command> [args]'];
1537
- for (const section of CLI_HELP_SECTIONS) {
1538
- blocks.push('', `${section.title}:`);
1539
- blocks.push(...section.lines.map((line) => ` ${line}`));
1540
- }
1541
- process.stdout.write(`${blocks.join('\n')}\n`);
1542
- }
1543
-
1544
- /**
1545
- * @returns {Promise<void>}
1546
- */
1547
- async function uninstallBrowserBridge() {
1548
- const cwd = process.cwd();
1549
- const globalProjectPath = os.homedir();
1550
-
1551
- for (const clientName of MCP_CLIENT_NAMES) {
1552
- await removeMcpConfig(clientName, { global: true, cwd });
1553
- }
1554
-
1555
- const removedGlobalSkillPaths = await removeAgentFiles({
1556
- targets: SUPPORTED_TARGETS,
1557
- projectPath: globalProjectPath,
1558
- global: true,
1559
- });
1560
- for (const removedPath of removedGlobalSkillPaths) {
1561
- process.stdout.write(`Removed ${removedPath}\n`);
1562
- }
1563
-
1564
- for (const clientName of MCP_CLIENT_NAMES) {
1565
- await removeMcpConfig(clientName, { global: false, cwd });
1566
- }
1567
-
1568
- const removedLocalSkillPaths = await removeAgentFiles({
1569
- targets: SUPPORTED_TARGETS,
1570
- projectPath: cwd,
1571
- global: false,
1572
- });
1573
- for (const removedPath of removedLocalSkillPaths) {
1574
- process.stdout.write(`Removed ${removedPath}\n`);
1575
- }
1576
-
1577
- for (const [index, browser] of SUPPORTED_BROWSERS.entries()) {
1578
- await uninstallNativeManifest({
1579
- browser,
1580
- removeBridgeDir: index === SUPPORTED_BROWSERS.length - 1,
1581
- });
1582
- }
1583
- }
1584
-
1585
- /**
1586
- * @param {string[]} args
1587
- * @returns {Promise<{ tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
1588
- */
1589
- async function parseCallCommand(args) {
1590
- const parsed = extractTabFlag(args);
1591
- const [first, second, ...extra] = parsed.rest;
1592
- if (!first) {
1593
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1594
- }
1595
- if (extra.length > 0) {
1596
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1597
- }
1598
-
1599
- if (first.includes('.')) {
1600
- const method = /** @type {BridgeMethod} */ (first);
1601
- if (!METHODS.includes(method)) {
1602
- throw new Error(`Unknown method "${first}". Run bbx skill to see available methods.`);
1603
- }
1604
- let rawParams = second;
1605
- // Support piped stdin: `echo '{"key":"val"}' | bbx call method -`
1606
- if (rawParams === '-') {
1607
- rawParams = await readStdin();
1608
- }
1609
- return {
1610
- method,
1611
- tabId: methodNeedsTab(method) ? parsed.tabId : null,
1612
- params: parseJsonObject(rawParams),
1613
- };
1614
- }
1615
-
1616
- throw new Error('Usage: call [--tab <tabId>] <method> [paramsJson]');
1617
- }
1618
-
1619
- /**
1620
- * Extract the global --remote flag from anywhere in the argument list.
1621
- * Exits with a usage error when the flag is present without a value.
1622
- *
1623
- * @param {string[]} args
1624
- * @returns {{ remoteId: string | null, explicit: boolean, rest: string[] }}
1625
- */
1626
- function extractRemoteFlag(args) {
1627
- const rest = [...args];
1628
- const index = rest.indexOf('--remote');
1629
- if (index === -1) {
1630
- return { remoteId: null, explicit: false, rest };
1631
- }
1632
- const remoteId = rest[index + 1];
1633
- if (!remoteId || remoteId.startsWith('--')) {
1634
- process.stderr.write('--remote requires a destination name (see `bbx remote list`).\n');
1635
- process.exit(1);
1636
- }
1637
- rest.splice(index, 2);
1638
- return { remoteId, explicit: true, rest };
1639
- }
1640
-
1641
- /**
1642
- * @param {string[]} args
1643
- * @returns {{ tabId: number | null, rest: string[] }}
1644
- */
1645
- function extractTabFlag(args) {
1646
- const rest = [...args];
1647
- let tabId = null;
1648
- const tabIndex = rest.indexOf('--tab');
1649
- if (tabIndex !== -1) {
1650
- tabId = parseIntArg(rest[tabIndex + 1], 'tabId');
1651
- rest.splice(tabIndex, 2);
1652
- }
1653
- return { tabId, rest };
1654
- }