fastbrowser_cli 1.0.21 → 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.
- package/README.md +55 -10
- package/dist/fastbrowser_cli/fastbrowser_cli.js +42 -72
- package/dist/fastbrowser_cli/fastbrowser_cli.js.map +1 -1
- package/dist/fastbrowser_cli/libs/query-builder.d.ts +17 -0
- package/dist/fastbrowser_cli/libs/query-builder.d.ts.map +1 -0
- package/dist/fastbrowser_cli/libs/query-builder.js +58 -0
- package/dist/fastbrowser_cli/libs/query-builder.js.map +1 -0
- package/dist/fastbrowser_cli/libs/server-manager.d.ts +8 -3
- package/dist/fastbrowser_cli/libs/server-manager.d.ts.map +1 -1
- package/dist/fastbrowser_cli/libs/server-manager.js +30 -15
- package/dist/fastbrowser_cli/libs/server-manager.js.map +1 -1
- package/dist/fastbrowser_httpd/fastbrowser_httpd.js +9 -6
- package/dist/fastbrowser_httpd/fastbrowser_httpd.js.map +1 -1
- package/dist/fastbrowser_httpd/libs/routes.d.ts +2 -1
- package/dist/fastbrowser_httpd/libs/routes.d.ts.map +1 -1
- package/dist/fastbrowser_httpd/libs/routes.js +2 -2
- package/dist/fastbrowser_httpd/libs/routes.js.map +1 -1
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js +3 -0
- package/dist/fastbrowser_mcp/fastbrowser_mcp.js.map +1 -1
- package/docs/articles/ai_workflow_to_shell_scripts.md +74 -16
- package/docs/articles/ai_workflow_to_shell_scripts.notes.md +23 -0
- package/docs/articles/ai_workflow_to_shell_scripts.outline.md +64 -0
- package/docs/publishing_workflow.md +136 -0
- package/examples/post-to-x.sh +4 -1
- package/examples/welcometothejungle/fastbrowser_helper.ts +39 -0
- package/examples/welcometothejungle/wttj-job.ts +132 -153
- package/examples/welcometothejungle/wttj-search.ts +61 -84
- package/package.json +48 -47
- package/src/fastbrowser_cli/fastbrowser_cli.ts +52 -92
- package/src/fastbrowser_cli/libs/query-builder.ts +89 -0
- package/src/fastbrowser_cli/libs/server-manager.ts +45 -17
- package/src/fastbrowser_httpd/fastbrowser_httpd.ts +14 -6
- package/src/fastbrowser_httpd/libs/routes.ts +3 -2
- package/src/fastbrowser_mcp/fastbrowser_mcp.ts +6 -0
- package/tests/http-client.test.ts +63 -0
- package/tests/query-builder.test.ts +204 -0
- package/tests/server-manager.test.ts +124 -0
- package/.playwright-mcp/.gitignore +0 -3
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 =
|
|
136
|
-
const targetSkillsDir =
|
|
83
|
+
const sourceSkillsDir = Path.resolve(import.meta.dirname, '../../skills');
|
|
84
|
+
const targetSkillsDir = Path.resolve(skillFolder, 'skills');
|
|
137
85
|
try {
|
|
138
|
-
const entries = await
|
|
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 =
|
|
146
|
-
const targetDir =
|
|
147
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
///////////////////////////////////////////////////////////////////////////////
|
|
@@ -347,7 +307,7 @@ async function main(): Promise<void> {
|
|
|
347
307
|
return prev;
|
|
348
308
|
})
|
|
349
309
|
.option('--limit <number>', 'Max nodes per selector (0 = unlimited)', '0')
|
|
350
|
-
.option('--with-ancestors', 'Include ancestor nodes',
|
|
310
|
+
.option('--with-ancestors', 'Include ancestor nodes', false)
|
|
351
311
|
.option('--no-with-ancestors', 'Exclude ancestor nodes')
|
|
352
312
|
.option('--selectors-json <json>', 'JSON array of {selector,limit,withAncestors} for per-selector control')
|
|
353
313
|
.action(async (opts: {
|
|
@@ -356,7 +316,7 @@ async function main(): Promise<void> {
|
|
|
356
316
|
withAncestors?: boolean;
|
|
357
317
|
selectorsJson?: string;
|
|
358
318
|
}, cmd: Command) => {
|
|
359
|
-
const body =
|
|
319
|
+
const body = QueryBuilder.buildQuerySelectorsBody(opts);
|
|
360
320
|
await MainHelper.runTool(cmd, 'query_selectors_all', body);
|
|
361
321
|
});
|
|
362
322
|
|
|
@@ -367,7 +327,7 @@ async function main(): Promise<void> {
|
|
|
367
327
|
prev.push(value);
|
|
368
328
|
return prev;
|
|
369
329
|
})
|
|
370
|
-
.option('--with-ancestors', 'Include ancestor nodes',
|
|
330
|
+
.option('--with-ancestors', 'Include ancestor nodes', false)
|
|
371
331
|
.option('--no-with-ancestors', 'Exclude ancestor nodes')
|
|
372
332
|
.option('--selectors-json <json>', 'JSON array of {selector,withAncestors} for per-selector control')
|
|
373
333
|
.action(async (opts: {
|
|
@@ -375,7 +335,7 @@ async function main(): Promise<void> {
|
|
|
375
335
|
withAncestors?: boolean;
|
|
376
336
|
selectorsJson?: string;
|
|
377
337
|
}, cmd: Command) => {
|
|
378
|
-
const body =
|
|
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<
|
|
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
|
-
|
|
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
|
|
47
|
-
if (state === 'running')
|
|
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
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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) {
|
|
@@ -50,6 +50,11 @@ class MainHelper {
|
|
|
50
50
|
private static async _getA11yText(mcpClient: McpMyClient): Promise<string> {
|
|
51
51
|
const mcpTarget = await mcpClient.getMcpTarget();
|
|
52
52
|
const toolConfig = await McpTargetHelper.targetToolTakeSnapshot(mcpTarget);
|
|
53
|
+
|
|
54
|
+
// FIXME: the first take_snapshot call after connecting to the MCP target often returns an empty snapshot for unknown reasons
|
|
55
|
+
// — working around this by calling it once and discarding the result before calling it again to get the actual snapshot text
|
|
56
|
+
await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
|
|
57
|
+
|
|
53
58
|
const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
|
|
54
59
|
const snapshotText = await ResponseFormatter.formatTakeSnapshot(mcpTarget, callToolResult);
|
|
55
60
|
// sanity check
|
|
@@ -228,6 +233,7 @@ class MainHelper {
|
|
|
228
233
|
const callToolResult = await mcpClient.callTool(toolConfig.toolName, toolConfig.toolArgs);
|
|
229
234
|
let outputStr = await ResponseFormatter.formatNavigatePage(mcpTarget, callToolResult);
|
|
230
235
|
|
|
236
|
+
|
|
231
237
|
return {
|
|
232
238
|
content: [{ type: "text", text: outputStr }],
|
|
233
239
|
};
|
|
@@ -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
|
+
});
|