fastbrowser_cli 1.0.22 → 1.0.25

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 (35) hide show
  1. package/README.md +55 -10
  2. package/dist/fastbrowser_cli/fastbrowser_cli.js +40 -70
  3. package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
  4. package/dist/fastbrowser_cli/libs/query-builder.d.ts +17 -0
  5. package/dist/fastbrowser_cli/libs/query-builder.d.ts.map +1 -0
  6. package/dist/fastbrowser_cli/libs/query-builder.js +58 -0
  7. package/dist/fastbrowser_cli/libs/query-builder.js.map +1 -0
  8. package/dist/fastbrowser_cli/libs/server-manager.d.ts +8 -3
  9. package/dist/fastbrowser_cli/libs/server-manager.d.ts.map +1 -1
  10. package/dist/fastbrowser_cli/libs/server-manager.js +30 -15
  11. package/dist/fastbrowser_cli/libs/server-manager.js.map +1 -1
  12. package/dist/fastbrowser_httpd/fastbrowser_httpd.js +9 -6
  13. package/dist/fastbrowser_httpd/fastbrowser_httpd.js.map +1 -1
  14. package/dist/fastbrowser_httpd/libs/routes.d.ts +2 -1
  15. package/dist/fastbrowser_httpd/libs/routes.d.ts.map +1 -1
  16. package/dist/fastbrowser_httpd/libs/routes.js +2 -2
  17. package/dist/fastbrowser_httpd/libs/routes.js.map +1 -1
  18. package/docs/articles/ai_workflow_to_shell_scripts.md +74 -16
  19. package/docs/articles/ai_workflow_to_shell_scripts.notes.md +23 -0
  20. package/docs/articles/ai_workflow_to_shell_scripts.outline.md +64 -0
  21. package/docs/publishing_workflow.md +136 -0
  22. package/examples/post-to-x.sh +3 -0
  23. package/examples/welcometothejungle/fastbrowser_helper.ts +39 -0
  24. package/examples/welcometothejungle/wttj-job.ts +132 -153
  25. package/examples/welcometothejungle/wttj-search.ts +61 -84
  26. package/package.json +48 -47
  27. package/src/fastbrowser_cli/fastbrowser_cli.ts +50 -90
  28. package/src/fastbrowser_cli/libs/query-builder.ts +89 -0
  29. package/src/fastbrowser_cli/libs/server-manager.ts +45 -17
  30. package/src/fastbrowser_httpd/fastbrowser_httpd.ts +14 -6
  31. package/src/fastbrowser_httpd/libs/routes.ts +3 -2
  32. package/tests/http-client.test.ts +63 -0
  33. package/tests/query-builder.test.ts +204 -0
  34. package/tests/server-manager.test.ts +124 -0
  35. package/.playwright-mcp/.gitignore +0 -3
@@ -1,20 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from 'node:fs';
4
- import path from 'node:path';
3
+ import Fs from 'node:fs';
4
+ import Path from 'node:path';
5
5
 
6
- import { Command, CommanderError } from 'commander';
6
+ import { Command, CommanderError, Option } from 'commander';
7
7
  import stringArgv from 'string-argv';
8
8
 
9
9
  import { HttpClient } from './libs/http-client.js';
10
10
  import { ServerManager } from './libs/server-manager.js';
11
- import type { QuerySelectorInput, QuerySelectorFirstInput, QuerySelectorsAllRequest, QuerySelectorRequest } from '../fastbrowser_httpd/libs/tool-schemas.js';
11
+ import { QueryBuilder } from './libs/query-builder.js';
12
+ import type { FastBrowserMcpTarget } from '../fastbrowser_mcp/fastbrowser_types.js';
12
13
 
13
14
  ///////////////////////////////////////////////////////////////////////////////
14
15
 
15
16
  type GlobalOpts = {
16
17
  server?: string;
17
18
  autostart?: boolean;
19
+ mcpTarget?: FastBrowserMcpTarget;
18
20
  };
19
21
 
20
22
  class SilentExitError extends Error {
@@ -38,113 +40,59 @@ class MainHelper {
38
40
 
39
41
  /**
40
42
  * Determine whether to auto-start the server based on command options (default: true)
41
- * @param cmd
42
- * @returns
43
+ * @param cmd
44
+ * @returns
43
45
  */
44
46
  static getAutostartFromCmd(cmd: Command): boolean {
45
47
  const globalOpts = cmd.optsWithGlobals<GlobalOpts>();
46
48
  return globalOpts.autostart !== false;
47
49
  }
48
50
 
51
+ static getMcpTargetFromCmd(cmd: Command): FastBrowserMcpTarget {
52
+ const globalOpts = cmd.optsWithGlobals<GlobalOpts>();
53
+ return globalOpts.mcpTarget ?? 'playwright';
54
+ }
55
+
49
56
  /**
50
57
  * Run a tool by making an HTTP request to the server, with optional auto-start
51
- * @param cmd
52
- * @param routeName
53
- * @param body
58
+ * @param cmd
59
+ * @param routeName
60
+ * @param body
54
61
  */
55
62
  static async runTool(cmd: Command, routeName: string, body: unknown): Promise<void> {
56
63
  const server = MainHelper.getServerUrlFromCmd(cmd);
64
+ const mcpTarget = MainHelper.getMcpTargetFromCmd(cmd);
57
65
  if (MainHelper.getAutostartFromCmd(cmd) === true) {
58
- await ServerManager.ensureRunning(server);
66
+ await ServerManager.ensureRunning(server, mcpTarget);
59
67
  }
60
68
  const response = await HttpClient.postTool(server, routeName, body);
61
69
  HttpClient.printResponse(response);
62
70
  }
63
71
 
64
- static buildQuerySelectorsBody(opts: {
65
- selector?: string[];
66
- limit?: string;
67
- withAncestors?: boolean;
68
- selectorsJson?: string;
69
- }): QuerySelectorsAllRequest {
70
- if (opts.selectorsJson !== undefined && opts.selectorsJson !== '') {
71
- let parsed: unknown;
72
- try {
73
- parsed = JSON.parse(opts.selectorsJson);
74
- } catch (err) {
75
- throw new Error(`--selectors-json is not valid JSON: ${(err as Error).message}`);
76
- }
77
- if (Array.isArray(parsed) === false) {
78
- throw new Error('--selectors-json must be a JSON array');
79
- }
80
- return { selectors: parsed as QuerySelectorInput[] };
81
- }
82
-
83
- const selectorList = opts.selector ?? [];
84
- if (selectorList.length === 0) {
85
- throw new Error('At least one --selector or --selectors-json is required');
86
- }
87
-
88
- const limit = opts.limit === undefined ? 0 : Number.parseInt(opts.limit, 10);
89
- if (Number.isNaN(limit) === true) {
90
- throw new Error(`Invalid --limit: ${opts.limit}`);
91
- }
92
- const withAncestors = opts.withAncestors !== false;
93
-
94
- const selectors: QuerySelectorInput[] = selectorList.map((selector) => ({
95
- selector,
96
- limit,
97
- withAncestors,
98
- }));
99
- return { selectors };
100
- }
101
-
102
- static buildQuerySelectorFirstBody(opts: {
103
- selector?: string[];
104
- withAncestors?: boolean;
105
- selectorsJson?: string;
106
- }): QuerySelectorRequest {
107
- if (opts.selectorsJson !== undefined && opts.selectorsJson !== '') {
108
- let parsed: unknown;
109
- try {
110
- parsed = JSON.parse(opts.selectorsJson);
111
- } catch (err) {
112
- throw new Error(`--selectors-json is not valid JSON: ${(err as Error).message}`);
113
- }
114
- if (Array.isArray(parsed) === false) {
115
- throw new Error('--selectors-json must be a JSON array');
116
- }
117
- return { selectors: parsed as QuerySelectorFirstInput[] };
118
- }
119
-
120
- const selectorList = opts.selector ?? [];
121
- if (selectorList.length === 0) {
122
- throw new Error('At least one --selector or --selectors-json is required');
72
+ static readPackageVersion(): string {
73
+ const packageJsonPath = Path.resolve(import.meta.dirname, '../../package.json');
74
+ const raw = Fs.readFileSync(packageJsonPath, 'utf-8');
75
+ const parsed = JSON.parse(raw) as { version?: string };
76
+ if (typeof parsed.version !== 'string') {
77
+ throw new Error(`fastbrowser-cli: missing "version" in ${packageJsonPath}`);
123
78
  }
124
-
125
- const withAncestors = opts.withAncestors !== false;
126
-
127
- const selectors: QuerySelectorFirstInput[] = selectorList.map((selector) => ({
128
- selector,
129
- withAncestors,
130
- }));
131
- return { selectors };
79
+ return parsed.version;
132
80
  }
133
81
 
134
82
  static async runInstall(skillFolder: string): Promise<void> {
135
- const sourceSkillsDir = path.resolve(import.meta.dirname, '../../skills');
136
- const targetSkillsDir = path.resolve(skillFolder, 'skills');
83
+ const sourceSkillsDir = Path.resolve(import.meta.dirname, '../../skills');
84
+ const targetSkillsDir = Path.resolve(skillFolder, 'skills');
137
85
  try {
138
- const entries = await fs.promises.readdir(sourceSkillsDir, { withFileTypes: true });
86
+ const entries = await Fs.promises.readdir(sourceSkillsDir, { withFileTypes: true });
139
87
  const skillDirs = entries.filter((entry) => entry.isDirectory() === true);
140
88
  if (skillDirs.length === 0) {
141
89
  console.error(`fastbrowser-cli error: no skills found in ${sourceSkillsDir}`);
142
90
  process.exit(1);
143
91
  }
144
92
  for (const skillDir of skillDirs) {
145
- const sourceDir = path.join(sourceSkillsDir, skillDir.name);
146
- const targetDir = path.join(targetSkillsDir, skillDir.name);
147
- await fs.promises.cp(sourceDir, targetDir, { recursive: true });
93
+ const sourceDir = Path.join(sourceSkillsDir, skillDir.name);
94
+ const targetDir = Path.join(targetSkillsDir, skillDir.name);
95
+ await Fs.promises.cp(sourceDir, targetDir, { recursive: true });
148
96
  console.log(`Installed ${skillDir.name} skill at ${targetDir}`);
149
97
  }
150
98
  } catch (err) {
@@ -159,7 +107,7 @@ class MainHelper {
159
107
  return inlineScript;
160
108
  }
161
109
  if (file !== undefined && file !== '') {
162
- return await fs.promises.readFile(file, 'utf-8');
110
+ return await Fs.promises.readFile(file, 'utf-8');
163
111
  }
164
112
  if (process.stdin.isTTY === true) {
165
113
  throw new Error('batch: no input. Provide a file path, --script, or pipe commands on stdin.');
@@ -185,6 +133,9 @@ class MainHelper {
185
133
  if (globalOpts.autostart === false) {
186
134
  globalFlags.push('--no-autostart');
187
135
  }
136
+ if (globalOpts.mcpTarget !== undefined) {
137
+ globalFlags.push('--mcp-target', globalOpts.mcpTarget);
138
+ }
188
139
 
189
140
  const lines = source.split('\n');
190
141
  let ok = 0;
@@ -232,9 +183,15 @@ async function main(): Promise<void> {
232
183
  program
233
184
  .name('fastbrowser-cli')
234
185
  .description('CLI client for fastbrowser')
186
+ .version(MainHelper.readPackageVersion(), '-V, --version', 'Print the fastbrowser-cli version')
235
187
  .option('--server <url>', 'fastbrowser-httpd URL (default: env FASTBROWSER_SERVER or http://localhost:8787)')
236
188
  .option('--autostart', 'Auto-start the server before a command if it is not running', true)
237
- .option('--no-autostart', 'Do not auto-start the server before a command');
189
+ .option('--no-autostart', 'Do not auto-start the server before a command')
190
+ .addOption(
191
+ new Option('--mcp-target <target>', 'browser backend (default: $FASTBROWSER_MCP_TARGET or playwright)')
192
+ .choices(['chrome_devtools', 'playwright'])
193
+ .default(process.env.FASTBROWSER_MCP_TARGET ?? 'playwright'),
194
+ );
238
195
 
239
196
  ///////////////////////////////////////////////////////////////////////////////
240
197
  ///////////////////////////////////////////////////////////////////////////////
@@ -251,7 +208,8 @@ async function main(): Promise<void> {
251
208
  .description('Start the fastbrowser HTTP server as a detached daemon')
252
209
  .action(async (_opts, cmd: Command) => {
253
210
  const serverUrl = MainHelper.getServerUrlFromCmd(cmd);
254
- await ServerManager.start(serverUrl);
211
+ const mcpTarget = MainHelper.getMcpTargetFromCmd(cmd);
212
+ await ServerManager.start(serverUrl, mcpTarget);
255
213
  });
256
214
 
257
215
  serverCmd
@@ -268,7 +226,8 @@ async function main(): Promise<void> {
268
226
  .action(async (_opts, cmd: Command) => {
269
227
  const serverUrl = MainHelper.getServerUrlFromCmd(cmd);
270
228
  const serverStatus = await ServerManager.status(serverUrl);
271
- console.log(`fastbrowser server at ${serverUrl}: ${serverStatus}`);
229
+ const targetSuffix = serverStatus.mcpTarget !== undefined ? ` (mcpTarget=${serverStatus.mcpTarget})` : '';
230
+ console.log(`fastbrowser server at ${serverUrl}: ${serverStatus.state}${targetSuffix}`);
272
231
  });
273
232
 
274
233
  serverCmd
@@ -276,8 +235,9 @@ async function main(): Promise<void> {
276
235
  .description('Restart the fastbrowser HTTP server')
277
236
  .action(async (_opts, cmd: Command) => {
278
237
  const serverUrl = MainHelper.getServerUrlFromCmd(cmd);
238
+ const mcpTarget = MainHelper.getMcpTargetFromCmd(cmd);
279
239
  await ServerManager.stop(serverUrl);
280
- await ServerManager.start(serverUrl);
240
+ await ServerManager.start(serverUrl, mcpTarget);
281
241
  });
282
242
  ///////////////////////////////////////////////////////////////////////////////
283
243
  ///////////////////////////////////////////////////////////////////////////////
@@ -356,7 +316,7 @@ async function main(): Promise<void> {
356
316
  withAncestors?: boolean;
357
317
  selectorsJson?: string;
358
318
  }, cmd: Command) => {
359
- const body = MainHelper.buildQuerySelectorsBody(opts);
319
+ const body = QueryBuilder.buildQuerySelectorsBody(opts);
360
320
  await MainHelper.runTool(cmd, 'query_selectors_all', body);
361
321
  });
362
322
 
@@ -375,7 +335,7 @@ async function main(): Promise<void> {
375
335
  withAncestors?: boolean;
376
336
  selectorsJson?: string;
377
337
  }, cmd: Command) => {
378
- const body = MainHelper.buildQuerySelectorFirstBody(opts);
338
+ const body = QueryBuilder.buildQuerySelectorFirstBody(opts);
379
339
  await MainHelper.runTool(cmd, 'query_selectors', body);
380
340
  });
381
341
 
@@ -0,0 +1,89 @@
1
+ // local imports
2
+ import type {
3
+ QuerySelectorInput,
4
+ QuerySelectorFirstInput,
5
+ QuerySelectorsAllRequest,
6
+ QuerySelectorRequest,
7
+ } from '../../fastbrowser_httpd/libs/tool-schemas.js';
8
+
9
+ ///////////////////////////////////////////////////////////////////////////////
10
+ ///////////////////////////////////////////////////////////////////////////////
11
+ //
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+
15
+ export type BuildQuerySelectorsAllOpts = {
16
+ selector?: string[];
17
+ limit?: string;
18
+ withAncestors?: boolean;
19
+ selectorsJson?: string;
20
+ };
21
+
22
+ export type BuildQuerySelectorFirstOpts = {
23
+ selector?: string[];
24
+ withAncestors?: boolean;
25
+ selectorsJson?: string;
26
+ };
27
+
28
+ export class QueryBuilder {
29
+ static buildQuerySelectorsBody(opts: BuildQuerySelectorsAllOpts): QuerySelectorsAllRequest {
30
+ if (opts.selectorsJson !== undefined && opts.selectorsJson !== '') {
31
+ let parsed: unknown;
32
+ try {
33
+ parsed = JSON.parse(opts.selectorsJson);
34
+ } catch (err) {
35
+ throw new Error(`--selectors-json is not valid JSON: ${(err as Error).message}`);
36
+ }
37
+ if (Array.isArray(parsed) === false) {
38
+ throw new Error('--selectors-json must be a JSON array');
39
+ }
40
+ return { selectors: parsed as QuerySelectorInput[] };
41
+ }
42
+
43
+ const selectorList = opts.selector ?? [];
44
+ if (selectorList.length === 0) {
45
+ throw new Error('At least one --selector or --selectors-json is required');
46
+ }
47
+
48
+ const limit = opts.limit === undefined ? 0 : Number.parseInt(opts.limit, 10);
49
+ if (Number.isNaN(limit) === true) {
50
+ throw new Error(`Invalid --limit: ${opts.limit}`);
51
+ }
52
+ const withAncestors = opts.withAncestors !== false;
53
+
54
+ const selectors: QuerySelectorInput[] = selectorList.map((selector) => ({
55
+ selector,
56
+ limit,
57
+ withAncestors,
58
+ }));
59
+ return { selectors };
60
+ }
61
+
62
+ static buildQuerySelectorFirstBody(opts: BuildQuerySelectorFirstOpts): QuerySelectorRequest {
63
+ if (opts.selectorsJson !== undefined && opts.selectorsJson !== '') {
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(opts.selectorsJson);
67
+ } catch (err) {
68
+ throw new Error(`--selectors-json is not valid JSON: ${(err as Error).message}`);
69
+ }
70
+ if (Array.isArray(parsed) === false) {
71
+ throw new Error('--selectors-json must be a JSON array');
72
+ }
73
+ return { selectors: parsed as QuerySelectorFirstInput[] };
74
+ }
75
+
76
+ const selectorList = opts.selector ?? [];
77
+ if (selectorList.length === 0) {
78
+ throw new Error('At least one --selector or --selectors-json is required');
79
+ }
80
+
81
+ const withAncestors = opts.withAncestors !== false;
82
+
83
+ const selectors: QuerySelectorFirstInput[] = selectorList.map((selector) => ({
84
+ selector,
85
+ withAncestors,
86
+ }));
87
+ return { selectors };
88
+ }
89
+ }
@@ -4,6 +4,9 @@ import Os from 'node:os';
4
4
  import Path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
 
7
+ // local imports
8
+ import type { FastBrowserMcpTarget } from '../../fastbrowser_mcp/fastbrowser_types.js';
9
+
7
10
  ///////////////////////////////////////////////////////////////////////////////
8
11
  ///////////////////////////////////////////////////////////////////////////////
9
12
  //
@@ -13,9 +16,15 @@ import { spawn } from 'node:child_process';
13
16
  type PidFile = {
14
17
  pid: number;
15
18
  port: number;
19
+ mcpTarget?: FastBrowserMcpTarget;
16
20
  startedAt: string;
17
21
  };
18
22
 
23
+ export type ServerStatus = {
24
+ state: 'running' | 'stopped';
25
+ mcpTarget?: FastBrowserMcpTarget;
26
+ };
27
+
19
28
  const STATE_DIRNAME = Path.join(Os.homedir(), '.fastbrowser_cli');
20
29
  const PID_FILENAME = Path.join(STATE_DIRNAME, 'server.json');
21
30
  const LOG_FILENAME = Path.join(STATE_DIRNAME, 'server.log');
@@ -27,34 +36,52 @@ const LOG_FILENAME = Path.join(STATE_DIRNAME, 'server.log');
27
36
  ///////////////////////////////////////////////////////////////////////////////
28
37
 
29
38
  export class ServerManager {
30
- static async status(serverUrl: string): Promise<'running' | 'stopped'> {
39
+ static async status(serverUrl: string): Promise<ServerStatus> {
31
40
  const base = serverUrl.replace(/\/+$/, '');
32
41
  try {
33
42
  const response = await fetch(`${base}/health`, {
34
43
  method: 'GET',
35
44
  signal: AbortSignal.timeout(500),
36
45
  });
37
- if (response.ok === false) return 'stopped';
38
- const payload = await response.json() as { ok?: unknown };
39
- return payload.ok === true ? 'running' : 'stopped';
46
+ if (response.ok === false) return { state: 'stopped' };
47
+ const payload = await response.json() as { ok?: unknown; mcpTarget?: unknown };
48
+ if (payload.ok !== true) return { state: 'stopped' };
49
+ const mcpTarget = payload.mcpTarget === 'chrome_devtools' || payload.mcpTarget === 'playwright'
50
+ ? payload.mcpTarget
51
+ : undefined;
52
+ return { state: 'running', mcpTarget };
40
53
  } catch {
41
- return 'stopped';
54
+ return { state: 'stopped' };
42
55
  }
43
56
  }
44
57
 
45
- static async ensureRunning(serverUrl: string): Promise<void> {
46
- const state = await ServerManager.status(serverUrl);
47
- if (state === 'running') return;
58
+ static async ensureRunning(serverUrl: string, mcpTarget: FastBrowserMcpTarget): Promise<void> {
59
+ const info = await ServerManager.status(serverUrl);
60
+ if (info.state === 'running') {
61
+ if (info.mcpTarget !== undefined && info.mcpTarget !== mcpTarget) {
62
+ throw new Error(
63
+ `fastbrowser server already running with mcpTarget=${info.mcpTarget}. ` +
64
+ `To switch to ${mcpTarget}, run: fastbrowser-cli --mcp-target ${mcpTarget} server restart`,
65
+ );
66
+ }
67
+ return;
68
+ }
48
69
 
49
70
  if (ServerManager.isLocalUrl(serverUrl) === false) {
50
71
  throw new Error(`fastbrowser server at ${serverUrl} is not reachable and cannot be auto-started (non-local URL)`);
51
72
  }
52
- await ServerManager.start(serverUrl);
73
+ await ServerManager.start(serverUrl, mcpTarget);
53
74
  }
54
75
 
55
- static async start(serverUrl: string): Promise<void> {
76
+ static async start(serverUrl: string, mcpTarget: FastBrowserMcpTarget): Promise<void> {
56
77
  const existing = await ServerManager.status(serverUrl);
57
- if (existing === 'running') {
78
+ if (existing.state === 'running') {
79
+ if (existing.mcpTarget !== undefined && existing.mcpTarget !== mcpTarget) {
80
+ throw new Error(
81
+ `fastbrowser server already running with mcpTarget=${existing.mcpTarget}. ` +
82
+ `Stop it first: fastbrowser-cli server stop`,
83
+ );
84
+ }
58
85
  console.error(`fastbrowser server already running at ${serverUrl}`);
59
86
  return;
60
87
  }
@@ -67,7 +94,7 @@ export class ServerManager {
67
94
  let entryPath = Path.resolve(import.meta.dirname, '..', '..', 'fastbrowser_httpd', 'fastbrowser_httpd.js');
68
95
  const port = ServerManager.parsePort(serverUrl);
69
96
  let spawnCommand = process.execPath;
70
- let spawnArgs = [entryPath, '--port', String(port)]
97
+ let spawnArgs = [entryPath, '--port', String(port), '--mcp-target', mcpTarget]
71
98
  // trick to work without being in `./dist'
72
99
  if (entryPath.includes('/dist/') === false) {
73
100
  spawnCommand = '/usr/local/bin/npx';
@@ -96,16 +123,17 @@ export class ServerManager {
96
123
  const pidFile: PidFile = {
97
124
  pid,
98
125
  port,
126
+ mcpTarget,
99
127
  startedAt: new Date().toISOString(),
100
128
  };
101
129
  Fs.writeFileSync(PID_FILENAME, JSON.stringify(pidFile, null, 2));
102
130
 
103
131
  const deadline = Date.now() + 10_000;
104
132
  while (Date.now() < deadline) {
105
- const state = await ServerManager.status(serverUrl);
106
- if (state === 'running') {
133
+ const info = await ServerManager.status(serverUrl);
134
+ if (info.state === 'running') {
107
135
  Fs.closeSync(logFd);
108
- console.error(`fastbrowser server started (pid=${pid}, port=${port})`);
136
+ console.error(`fastbrowser server started (pid=${pid}, port=${port}, mcpTarget=${mcpTarget})`);
109
137
  return;
110
138
  }
111
139
  await ServerManager.sleep(500);
@@ -161,8 +189,8 @@ export class ServerManager {
161
189
  }
162
190
 
163
191
  // Best-effort: ensure the HTTP server is actually down from the caller's perspective.
164
- const state = await ServerManager.status(serverUrl);
165
- if (state === 'running') {
192
+ const info = await ServerManager.status(serverUrl);
193
+ if (info.state === 'running') {
166
194
  console.error(`warning: process ${pid} killed but ${serverUrl} still responds to /health`);
167
195
  return;
168
196
  }
@@ -4,11 +4,12 @@
4
4
  import Path from 'node:path';
5
5
 
6
6
  // npm imports
7
- import { Command } from 'commander';
7
+ import { Command, Option } from 'commander';
8
8
  import express from 'express';
9
9
 
10
10
  // local imports
11
11
  import { McpMyClient } from '../fastbrowser_mcp/libs/mcp_my_client.js';
12
+ import type { FastBrowserMcpTarget } from '../fastbrowser_mcp/fastbrowser_types.js';
12
13
  import { Routes } from './libs/routes.js';
13
14
 
14
15
 
@@ -21,15 +22,17 @@ import { Routes } from './libs/routes.js';
21
22
  class MainHelper {
22
23
  static async commandStart({
23
24
  port,
25
+ mcpTarget,
24
26
  verbose = false,
25
27
  }: {
26
28
  port: number;
29
+ mcpTarget: FastBrowserMcpTarget;
27
30
  verbose?: boolean;
28
31
  }): Promise<void> {
29
32
  // Spawn fastbrowser-mcp as a subprocess and hold a persistent MCP client to it.
30
33
  const fastbrowserMcpEntry = Path.resolve(import.meta.dirname, '..', 'fastbrowser_mcp', 'fastbrowser_mcp.js');
31
34
  let mcpServerCommand = process.execPath;
32
- let mcpServerArgs = [fastbrowserMcpEntry, 'mcp_server'];
35
+ let mcpServerArgs = [fastbrowserMcpEntry, 'mcp_server', '--mcp_target', mcpTarget];
33
36
  // trick to work without being in `./dist'
34
37
  if (fastbrowserMcpEntry.includes('/dist/') === false) {
35
38
  mcpServerCommand = '/usr/local/bin/npx';
@@ -40,7 +43,7 @@ class MainHelper {
40
43
  const mcpClient = new McpMyClient({
41
44
  name: 'fastbrowser-httpd',
42
45
  version: '1.0.0',
43
- mcpTarget: 'chrome_devtools',
46
+ mcpTarget,
44
47
  transport: {
45
48
  type: 'stdio',
46
49
  command: mcpServerCommand,
@@ -62,7 +65,7 @@ class MainHelper {
62
65
 
63
66
  const app = express();
64
67
  app.use(express.json({ limit: '2mb' }));
65
- Routes.register(app, mcpClient);
68
+ Routes.register(app, mcpClient, mcpTarget);
66
69
 
67
70
  const server = app.listen(port, () => {
68
71
  console.error(`fastbrowser-httpd listening on http://localhost:${port}`);
@@ -93,13 +96,18 @@ async function main(): Promise<void> {
93
96
  .description('Persistent HTTP server fronting fastbrowser-mcp')
94
97
  .option('-p, --port <number>', 'Port to listen on', '8787')
95
98
  .option('-v, --verbose', 'Enable verbose logging')
96
- .action(async (opts: { port: string; verbose?: boolean }) => {
99
+ .addOption(
100
+ new Option('--mcp-target <target>', 'browser backend (default: $FASTBROWSER_MCP_TARGET or playwright)')
101
+ .choices(['chrome_devtools', 'playwright'])
102
+ .default(process.env.FASTBROWSER_MCP_TARGET ?? 'playwright'),
103
+ )
104
+ .action(async (opts: { port: string; verbose?: boolean; mcpTarget: FastBrowserMcpTarget }) => {
97
105
  const port = Number.parseInt(opts.port, 10);
98
106
  if (Number.isNaN(port) === true) {
99
107
  console.error(`Invalid --port: ${opts.port}`);
100
108
  process.exit(1);
101
109
  }
102
- await MainHelper.commandStart({ port, verbose: opts.verbose });
110
+ await MainHelper.commandStart({ port, mcpTarget: opts.mcpTarget, verbose: opts.verbose });
103
111
  });
104
112
 
105
113
  await program.parseAsync(process.argv);
@@ -3,6 +3,7 @@ import type { Express, Request, Response } from 'express';
3
3
 
4
4
  // local imports
5
5
  import { McpMyClient } from '../../fastbrowser_mcp/libs/mcp_my_client.js';
6
+ import type { FastBrowserMcpTarget } from '../../fastbrowser_mcp/fastbrowser_types.js';
6
7
  import { TOOL_SCHEMAS, ToolResponseSchema } from './tool-schemas.js';
7
8
 
8
9
  ///////////////////////////////////////////////////////////////////////////////
@@ -12,9 +13,9 @@ import { TOOL_SCHEMAS, ToolResponseSchema } from './tool-schemas.js';
12
13
  ///////////////////////////////////////////////////////////////////////////////
13
14
 
14
15
  export class Routes {
15
- static register(app: Express, mcpClient: McpMyClient): void {
16
+ static register(app: Express, mcpClient: McpMyClient, mcpTarget: FastBrowserMcpTarget): void {
16
17
  app.get('/health', (_req: Request, res: Response) => {
17
- res.json({ ok: true });
18
+ res.json({ ok: true, mcpTarget });
18
19
  });
19
20
 
20
21
  for (const entry of TOOL_SCHEMAS) {
@@ -0,0 +1,63 @@
1
+ // node imports
2
+ import { describe, it, beforeEach, afterEach } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ // local imports
6
+ import { HttpClient } from '../src/fastbrowser_cli/libs/http-client.js';
7
+
8
+ ///////////////////////////////////////////////////////////////////////////////
9
+ ///////////////////////////////////////////////////////////////////////////////
10
+ //
11
+ ///////////////////////////////////////////////////////////////////////////////
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ describe('HttpClient.getServerUrl', () => {
15
+ let originalEnv: string | undefined;
16
+
17
+ beforeEach(() => {
18
+ originalEnv = process.env['FASTBROWSER_SERVER'];
19
+ delete process.env['FASTBROWSER_SERVER'];
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (originalEnv !== undefined) {
24
+ process.env['FASTBROWSER_SERVER'] = originalEnv;
25
+ } else {
26
+ delete process.env['FASTBROWSER_SERVER'];
27
+ }
28
+ });
29
+
30
+ it('returns the override URL when one is provided', () => {
31
+ const url = HttpClient.getServerUrl('http://example.com:1234');
32
+ assert.equal(url, 'http://example.com:1234');
33
+ });
34
+
35
+ it('prefers the override URL over the env var', () => {
36
+ process.env['FASTBROWSER_SERVER'] = 'http://from-env:9999';
37
+ const url = HttpClient.getServerUrl('http://override:1111');
38
+ assert.equal(url, 'http://override:1111');
39
+ });
40
+
41
+ it('falls back to FASTBROWSER_SERVER env var when override is undefined', () => {
42
+ process.env['FASTBROWSER_SERVER'] = 'http://from-env:9999';
43
+ const url = HttpClient.getServerUrl(undefined);
44
+ assert.equal(url, 'http://from-env:9999');
45
+ });
46
+
47
+ it('falls back to FASTBROWSER_SERVER env var when override is empty string', () => {
48
+ process.env['FASTBROWSER_SERVER'] = 'http://from-env:9999';
49
+ const url = HttpClient.getServerUrl('');
50
+ assert.equal(url, 'http://from-env:9999');
51
+ });
52
+
53
+ it('ignores empty FASTBROWSER_SERVER env var', () => {
54
+ process.env['FASTBROWSER_SERVER'] = '';
55
+ const url = HttpClient.getServerUrl(undefined);
56
+ assert.equal(url, 'http://localhost:8787');
57
+ });
58
+
59
+ it('returns the default URL when nothing is set', () => {
60
+ const url = HttpClient.getServerUrl(undefined);
61
+ assert.equal(url, 'http://localhost:8787');
62
+ });
63
+ });