@mearl/client 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +114 -3
- package/dist/commands.d.ts +12 -9
- package/dist/commands.js +18 -7
- package/dist/generated-browser-action-protocol.d.ts +20 -4
- package/dist/plugin.d.ts +17 -0
- package/dist/plugin.js +167 -0
- package/dist/pluginRegistry.d.ts +11 -0
- package/dist/pluginRegistry.js +108 -0
- package/dist/socket.js +3 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -69,7 +69,9 @@ mearl browser_launch --payload '{"name":"account-b","query":"test_account"}'
|
|
|
69
69
|
mearl browser_launch --payload '{"name":"cookie-copy","copyCookieDomains":["example.com"]}'
|
|
70
70
|
mearl browser_launch --payload '{"name":"cloud-login","provider":"agentbay","persistent":true,"userAgentMode":"desktop","copyCookieDomains":["example.com"],"url":"https://example.com/account"}'
|
|
71
71
|
mearl browser_launch --browser '<控制浏览器 browserId>' --payload '{"name":"cloud-login","provider":"agentbay","reuse":"require","copyCookieDomains":["example.com"]}'
|
|
72
|
+
mearl browser_launch --browser '<本地来源 browserId>' --payload '{"sessionId":"<其他设备创建的 AgentBay sessionId>","copyCookieDomains":["example.com"]}'
|
|
72
73
|
mearl browser_launch --payload '{"name":"cloud-tools","provider":"agentbay","copyCookieDomains":"mearl-services"}'
|
|
74
|
+
mearl browser_launch --payload '{"name":"cloud-tools-plus","provider":"agentbay","copyCookieDomains":{"presets":["mearl-services"],"domains":["figma.com"]}}'
|
|
73
75
|
mearl browser_launch --payload '{"name":"figma","userAgentMode":"desktop","copyCookieDomains":["figma.com"]}'
|
|
74
76
|
mearl browser_list
|
|
75
77
|
mearl browser_release
|
|
@@ -95,6 +97,41 @@ mearl browser_release
|
|
|
95
97
|
|
|
96
98
|
`check` 有独立的选项说明,运行 `mearl check --help` 查看。其中 `--json` 仅用于输出机器可读的检查结果。
|
|
97
99
|
|
|
100
|
+
## Skill CLI 插件
|
|
101
|
+
|
|
102
|
+
安装 skill 并自动注册命令,或直接注册已有入口:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npx @mearl/setup plugin install trip/mearl --skill yuque-doc-fetch --yes
|
|
106
|
+
npx @mearl/setup plugin yuque /absolute/path/to/yuque-doc-fetch
|
|
107
|
+
npx @mearl/setup plugin uninstall yuque
|
|
108
|
+
mearl yuque read "https://aliyuque.antfin.com/group/book/doc"
|
|
109
|
+
mearl yuque --payload '{"command":"read","input":{"url":"https://aliyuque.antfin.com/group/book/doc"}}'
|
|
110
|
+
mearl yuque --help
|
|
111
|
+
mearl yuque --version
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
目录默认使用 `scripts/main.mjs`。入口默认导出 `register(app)`,通过 `app.command(signature, handler, description?)`
|
|
115
|
+
注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。CLI 默认只输出最终结果,传入
|
|
116
|
+
`--verbose` 时才把进度写入 stderr。handler 返回文本或 JSON,宿主统一处理 `--output`、连接和错误。写命令在用户调用后直接执行,业务模块负责目标、状态、幂等和结果校验。
|
|
117
|
+
自动注册默认以 skill 安装目录名作为命令名,也可在入口导出 `name` 指定短名称。
|
|
118
|
+
setup 会补齐缺失或低于自身版本的 client、native-host;安装参数透传给 Ali Skills,并固定全局共享安装。
|
|
119
|
+
|
|
120
|
+
需要单独检查插件时运行 `mearl <name> --version`。命令会解析注册记录并加载入口,但不连接浏览器;
|
|
121
|
+
退出码为 0 表示插件已注册且入口可加载,stdout 返回同目录 `SKILL.md` 的版本,缺少版本元数据时返回 `unknown`。
|
|
122
|
+
未注册、入口丢失或加载失败会以退出码 2 和对应的 `PLUGIN_*` 错误结束。普通业务调用无需预先检查,直接处理同样的错误即可。
|
|
123
|
+
|
|
124
|
+
插件接受位置参数或包含 command/input 的 `--payload`,两种形式不能混用。JSON 保留业务字段类型;正文等文件输入
|
|
125
|
+
按具体 skill 的字段约定处理。插件不使用上表中内置 action 的 `--payload-file` 入口。
|
|
126
|
+
|
|
127
|
+
注册记录在 `~/.mearl/plugins/<name>.json`,只引用入口路径;skill 原地更新直接生效,换路径后重新注册。
|
|
128
|
+
`plugin uninstall <name>` 会对 `~/.agents/skills` 中的全局 Skill 调用 `ali-skills remove --global --yes`,
|
|
129
|
+
成功后清除相关注册;即使入口目录已被手动删除,也会完成 Ali Skills 跟踪记录的清理。
|
|
130
|
+
手工路径只解除注册并保留源码。
|
|
131
|
+
`PLUGIN_NOT_REGISTERED` 表示插件尚未注册。`PLUGIN_ENTRY_MISSING` 会显示失效入口,并给出带有实际插件名的
|
|
132
|
+
卸载清理命令。其他错误按实际原因处理。
|
|
133
|
+
编写规范见 [Mearl Skill Creator 的 CLI 插件规范](../skills/mearl-skill-creator/references/cli-plugin.md)。
|
|
134
|
+
|
|
98
135
|
## 支持的操作
|
|
99
136
|
|
|
100
137
|
| 分类 | Actions |
|
|
@@ -120,9 +157,9 @@ mearl browser_release
|
|
|
120
157
|
|
|
121
158
|
`browser_list` 统一列出普通浏览器和托管浏览器。`type` 区分 `regular` / `managed`,`status` 区分 `connected` / `running_disconnected` / `stopped`;只有 `connected` 的浏览器可作为操作目标。
|
|
122
159
|
|
|
123
|
-
`browser_release`
|
|
160
|
+
按 `sessionId` 接入的 AgentBay 浏览器用 `browser_release` 收尾:它会移除本机 attachment 并退出对应宿主,但不会关闭云端 Session。其他浏览器仅在用户明确要求结束调试、退出接管或清理会话资源时调用;它会释放 debugger/CDP 控制和会话级临时状态(包括设备/时区模拟),保留浏览器及全部标签页,后续浏览器操作可自动重新建立控制。
|
|
124
161
|
|
|
125
|
-
托管浏览器使用独立 Profile。控制浏览器需先登录 TDBank;生成的 SSO 地址在本地内部传递,本地实例可以使用 `headless: true`(默认)完成测试账号登录。AgentBay 固定为非 headless,传 `headless: true` 会被拒绝;`imageId` 可指定镜像别名或具体镜像 ID,省略时使用已验证的 Linux Browser Use 内网镜像。`persistent: true` 会让本地实例保留 Profile,让 AgentBay 同名实例绑定稳定的云端 Browser Context;正常关闭时同步 Cookie、LocalStorage、IndexedDB 等状态,下次启动无需再次复制本地登录态。`deleteProfile: true` 会显式删除对应 Profile 或 Context。AgentBay 的 `reuse` 支持 `never`(默认)、`prefer` 和 `require`:`prefer` 存在运行实例时复用、否则创建,`require`
|
|
162
|
+
托管浏览器使用独立 Profile。控制浏览器需先登录 TDBank;生成的 SSO 地址在本地内部传递,本地实例可以使用 `headless: true`(默认)完成测试账号登录。AgentBay 固定为非 headless,传 `headless: true` 会被拒绝;`imageId` 可指定镜像别名或具体镜像 ID,省略时使用已验证的 Linux Browser Use 内网镜像。`persistent: true` 会让本地实例保留 Profile,让 AgentBay 同名实例绑定稳定的云端 Browser Context;正常关闭时同步 Cookie、LocalStorage、IndexedDB 等状态,下次启动无需再次复制本地登录态。`deleteProfile: true` 会显式删除对应 Profile 或 Context。AgentBay 的 `reuse` 支持 `never`(默认)、`prefer` 和 `require`:`prefer` 存在运行实例时复用、否则创建,`require` 只复用且不会意外创建计费会话。另一台设备已知确切 Session 时,可传 `sessionId` 直接通过 AgentBay CDP 接入,不需要 cloud-server;当前设备需配置能访问该 Session 的 `AGENTBAY_KEY`。provider-local ID 为 `managed:agentbay:session:<sessionId>`,跨 CLI 调用使用启动响应中的全局 `browserId`,且本机只持有 attachment。`copyCookieDomains` 传数组时复制指定域,传 `"mearl-services"` 时同步 Mearl 依赖平台登录态;需要同时复制基础登录态和额外站点时传 `{ "presets": ["mearl-services"], "domains": [...] }`。Cookie 值不会经过 cloud-server,也不会出现在命令结果或日志中;`userAgentMode: "desktop"` 让本地实例使用匹配本机 Chrome 版本的桌面 UA,AgentBay 则复用当前控制浏览器的桌面 UA。`browser_list` 仅返回浏览器的稳定配置,不携带 AgentBay 串流入口或沙箱 noVNC 地址。需要时显式运行 `mearl check --browser <id|名称>`,由统一的浏览器访问入口能力实时返回 `browserAccess`;AgentBay、OpenSandbox 和 AONE Sandbox 地址都应按敏感信息处理。完整设计见 [托管浏览器与 TDBank 多账号设计](../../docs/managed-browsers.md)。
|
|
126
163
|
|
|
127
164
|
## 架构
|
|
128
165
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface ParsedArgs {
|
|
|
16
16
|
serverUrlSource?: string;
|
|
17
17
|
timeoutSec: number;
|
|
18
18
|
timeoutOption?: string;
|
|
19
|
+
pluginArgs?: string[];
|
|
20
|
+
help?: boolean;
|
|
21
|
+
version?: boolean;
|
|
22
|
+
verbose?: boolean;
|
|
23
|
+
hasPayload?: boolean;
|
|
19
24
|
}
|
|
20
|
-
export declare function parseClientArgs(args: string[]): ParsedArgs;
|
|
25
|
+
export declare function parseClientArgs(args: string[], plugin?: boolean): ParsedArgs;
|
|
21
26
|
export declare function runMearlCli(args: string[], options?: MearlCliOptions): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { commandInput, findCommandSuggestions, formatCommandSuggestions, } from
|
|
|
7
7
|
import { COMMANDS, COMMAND_MAP } from './commands.js';
|
|
8
8
|
import { CLIENT_VERSION } from './socket.js';
|
|
9
9
|
import { UnifiedClient } from './unified.js';
|
|
10
|
+
import { findPluginEntry, listPluginNames, loadPlugin, PluginError } from './plugin.js';
|
|
10
11
|
import { getCloudServerConfigPath, loadCloudServerConfig } from './unifiedConfig.js';
|
|
11
12
|
const CATEGORIES = [
|
|
12
13
|
{
|
|
@@ -100,6 +101,14 @@ function printUsage(commandName, version) {
|
|
|
100
101
|
lines.push(` ${name.padEnd(26)}${command.summary ?? command.description}`);
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
try {
|
|
105
|
+
const plugins = listPluginNames();
|
|
106
|
+
if (plugins.length)
|
|
107
|
+
lines.push('', '已注册插件:', ...plugins.map(name => ` ${name}`));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
lines.push('', error instanceof Error ? error.message : String(error));
|
|
111
|
+
}
|
|
103
112
|
lines.push('', `运行 ${commandName} <action> --help 查看 action 参数。`);
|
|
104
113
|
console.error(lines.join('\n'));
|
|
105
114
|
}
|
|
@@ -162,7 +171,7 @@ function printCommandHelp(commandName, command) {
|
|
|
162
171
|
function printUnknownCommand(commandName, args) {
|
|
163
172
|
const input = commandInput(args) || args[0] || '';
|
|
164
173
|
const suggestions = findCommandSuggestions(args, SUGGESTABLE_COMMANDS);
|
|
165
|
-
const lines = [
|
|
174
|
+
const lines = [`PLUGIN_NOT_REGISTERED: 不支持的命令: ${input}`];
|
|
166
175
|
if (suggestions.length > 0) {
|
|
167
176
|
lines.push('', '你可能想用:', ...formatCommandSuggestions(commandName, suggestions));
|
|
168
177
|
}
|
|
@@ -213,7 +222,7 @@ function resolveServer(explicit) {
|
|
|
213
222
|
? { serverUrl: config.server, serverUrlSource: getCloudServerConfigPath() }
|
|
214
223
|
: {};
|
|
215
224
|
}
|
|
216
|
-
export function parseClientArgs(args) {
|
|
225
|
+
export function parseClientArgs(args, plugin = false) {
|
|
217
226
|
const program = new Command()
|
|
218
227
|
.name('mearl')
|
|
219
228
|
.helpOption(false)
|
|
@@ -232,6 +241,12 @@ export function parseClientArgs(args) {
|
|
|
232
241
|
.option('--local')
|
|
233
242
|
.option('--cdp')
|
|
234
243
|
.option('--server <url>');
|
|
244
|
+
if (plugin)
|
|
245
|
+
program
|
|
246
|
+
.argument('[pluginArgs...]')
|
|
247
|
+
.option('-h, --help')
|
|
248
|
+
.option('-v, --version')
|
|
249
|
+
.option('--verbose');
|
|
235
250
|
try {
|
|
236
251
|
program.parse(['node', 'mearl', ...args]);
|
|
237
252
|
}
|
|
@@ -243,6 +258,9 @@ export function parseClientArgs(args) {
|
|
|
243
258
|
}
|
|
244
259
|
const action = program.processedArgs[0];
|
|
245
260
|
const options = program.opts();
|
|
261
|
+
if (plugin && options.payloadFile !== undefined) {
|
|
262
|
+
throw new Error('插件使用 --payload 传入 command 和 input;正文文件可由业务字段引用');
|
|
263
|
+
}
|
|
246
264
|
const json = options.json === true;
|
|
247
265
|
if (json && action !== 'check')
|
|
248
266
|
throw new Error('--json 仅适用于 check');
|
|
@@ -266,8 +284,85 @@ export function parseClientArgs(args) {
|
|
|
266
284
|
...(options.browser ? { browser: options.browser } : {}),
|
|
267
285
|
...(connector ? { connector } : {}),
|
|
268
286
|
...resolveServer(options.server),
|
|
287
|
+
...(plugin
|
|
288
|
+
? {
|
|
289
|
+
pluginArgs: program.processedArgs[1],
|
|
290
|
+
help: options.help === true,
|
|
291
|
+
version: options.version === true,
|
|
292
|
+
verbose: options.verbose === true,
|
|
293
|
+
hasPayload: options.payload !== undefined,
|
|
294
|
+
}
|
|
295
|
+
: {}),
|
|
269
296
|
};
|
|
270
297
|
}
|
|
298
|
+
async function runPluginCli(args, entry, commandName) {
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = parseClientArgs(args, true);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
307
|
+
let client;
|
|
308
|
+
try {
|
|
309
|
+
const invokeOptions = {
|
|
310
|
+
...(parsed.timeoutOption ? { timeoutSec: parsed.timeoutSec } : {}),
|
|
311
|
+
...(parsed.browser ? { browser: parsed.browser } : {}),
|
|
312
|
+
...(parsed.connector ? { connector: parsed.connector } : {}),
|
|
313
|
+
...(parsed.local ? { local: true } : {}),
|
|
314
|
+
...(parsed.forceCdp ? { transport: 'cdp' } : {}),
|
|
315
|
+
};
|
|
316
|
+
const plugin = await loadPlugin(entry, {
|
|
317
|
+
options: invokeOptions,
|
|
318
|
+
progress(message) {
|
|
319
|
+
if (parsed.verbose) {
|
|
320
|
+
process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
async invoke(action, payload, options) {
|
|
324
|
+
client ??= new UnifiedClient({
|
|
325
|
+
...(parsed.serverUrl ? { serverUrl: parsed.serverUrl } : {}),
|
|
326
|
+
});
|
|
327
|
+
const result = await client.invoke(action, payload, { ...invokeOptions, ...options });
|
|
328
|
+
if (result?.action?.success === false)
|
|
329
|
+
throw new Error(result.action.error || `${action} failed`);
|
|
330
|
+
return result;
|
|
331
|
+
},
|
|
332
|
+
}, parsed.action);
|
|
333
|
+
if (parsed.version) {
|
|
334
|
+
console.log(plugin.version ?? 'unknown');
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
if (parsed.help || (!parsed.pluginArgs?.length && !parsed.hasPayload)) {
|
|
338
|
+
console.error(plugin.help(`${commandName} ${parsed.action}`, parsed.pluginArgs?.[0]));
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
const result = await plugin.run(parsed.pluginArgs ?? [], parsed.hasPayload ? parsed.payload : undefined);
|
|
342
|
+
if (result !== undefined) {
|
|
343
|
+
if (parsed.outputPath)
|
|
344
|
+
writeOutput('', result, parsed.outputPath);
|
|
345
|
+
else if (typeof result === 'string')
|
|
346
|
+
process.stdout.write(result.endsWith('\n') ? result : `${result}\n`);
|
|
347
|
+
else
|
|
348
|
+
process.stdout.write(`${JSON.stringify(result, null, parsed.compact ? undefined : 2)}\n`);
|
|
349
|
+
}
|
|
350
|
+
return result &&
|
|
351
|
+
typeof result === 'object' &&
|
|
352
|
+
!Array.isArray(result) &&
|
|
353
|
+
'success' in result &&
|
|
354
|
+
result.success === false
|
|
355
|
+
? 1
|
|
356
|
+
: 0;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
360
|
+
return error instanceof PluginError ? 2 : 1;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
client?.disconnect();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
271
366
|
function screenshotData(action, result) {
|
|
272
367
|
if (typeof result === 'string' && result.startsWith('data:image')) {
|
|
273
368
|
return result.split(',')[1] ?? null;
|
|
@@ -294,7 +389,23 @@ export async function runMearlCli(args, options = {}) {
|
|
|
294
389
|
const clientVersion = options.clientVersion ?? CLIENT_VERSION;
|
|
295
390
|
if (args[0] === 'capture_checkpoint')
|
|
296
391
|
args = ['tab_checkpoint', ...args.slice(1)];
|
|
297
|
-
if (args
|
|
392
|
+
if (args[0] &&
|
|
393
|
+
!args[0].startsWith('-') &&
|
|
394
|
+
!args[0].startsWith('__') &&
|
|
395
|
+
args[0] !== 'check' &&
|
|
396
|
+
!ADVANCED_BUILTIN_COMMANDS.has(args[0]) &&
|
|
397
|
+
!COMMAND_MAP.has(args[0])) {
|
|
398
|
+
try {
|
|
399
|
+
const entry = findPluginEntry(args[0]);
|
|
400
|
+
if (entry)
|
|
401
|
+
return await runPluginCli(args, entry, commandName);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
405
|
+
return 2;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
298
409
|
console.log(clientVersion);
|
|
299
410
|
return 0;
|
|
300
411
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -386,21 +386,24 @@ export declare const COMMANDS: readonly [{
|
|
|
386
386
|
readonly examples: ["browser_list"];
|
|
387
387
|
}, {
|
|
388
388
|
readonly name: "browser_release";
|
|
389
|
-
readonly description: "
|
|
390
|
-
readonly summary: "
|
|
389
|
+
readonly description: "sessionId 接入的 AgentBay 浏览器用它解除本机 attachment且不关闭云端 Session;其他浏览器仅按用户明确要求释放调试控制和会话级临时效果(含设备/时区模拟),不用于普通任务收尾";
|
|
390
|
+
readonly summary: "释放浏览器控制或解除 Session attachment";
|
|
391
391
|
readonly examples: ["browser_release", "browser_release --browser chrome-a"];
|
|
392
392
|
}, {
|
|
393
393
|
readonly name: "browser_launch";
|
|
394
|
-
readonly description: "启动本地 Chrome
|
|
394
|
+
readonly description: "启动本地 Chrome、创建/复用同名 AgentBay 云浏览器,或用 sessionId 接入现有云端 Session";
|
|
395
395
|
readonly params: [{
|
|
396
396
|
readonly name: "name";
|
|
397
397
|
readonly type: "string";
|
|
398
|
-
readonly
|
|
399
|
-
readonly description: "实例名称,最多 64 个字符";
|
|
398
|
+
readonly description: "实例名称,创建浏览器时必填;按 sessionId 接入时可省略";
|
|
400
399
|
}, {
|
|
401
400
|
readonly name: "provider";
|
|
402
401
|
readonly type: "\"local\" | \"agentbay\"";
|
|
403
|
-
readonly description: "运行环境,默认 local
|
|
402
|
+
readonly description: "运行环境,默认 local;传 sessionId 时自动使用 agentbay,否则 agentbay 从 AGENTBAY_KEY 创建云浏览器";
|
|
403
|
+
}, {
|
|
404
|
+
readonly name: "sessionId";
|
|
405
|
+
readonly type: "string";
|
|
406
|
+
readonly description: "显式接入现有 AgentBay Session;不会创建、初始化或取得其生命周期所有权";
|
|
404
407
|
}, {
|
|
405
408
|
readonly name: "reuse";
|
|
406
409
|
readonly type: "\"never\" | \"prefer\" | \"require\"";
|
|
@@ -451,10 +454,10 @@ export declare const COMMANDS: readonly [{
|
|
|
451
454
|
readonly description: "CDP 控制浏览器中用于读取 TDBank Cookie 的标签页";
|
|
452
455
|
}, {
|
|
453
456
|
readonly name: "copyCookieDomains";
|
|
454
|
-
readonly type: "string[] | \"mearl-services\"";
|
|
455
|
-
readonly description: "从控制浏览器复制 Cookie
|
|
457
|
+
readonly type: "string[] | \"mearl-services\" | { presets?: [\"mearl-services\"]; domains?: string[] }";
|
|
458
|
+
readonly description: "从控制浏览器复制 Cookie:可传域名数组、Mearl 登录态预设,或组合预设与额外域名";
|
|
456
459
|
}];
|
|
457
|
-
readonly examples: ["browser_launch --payload '{\"name\":\"cloud-a\",\"provider\":\"agentbay\",\"userAgentMode\":\"desktop\",\"url\":\"https://example.com\"}'", "browser_launch --payload '{\"name\":\"cloud-public\",\"provider\":\"agentbay\",\"imageId\":\"browser_latest\",\"url\":\"https://example.com\"}'", "browser_launch --payload '{\"name\":\"cloud-login\",\"provider\":\"agentbay\",\"persistent\":true,\"copyCookieDomains\":[\"example.com\"],\"url\":\"https://example.com/account\"}'", "browser_launch --payload '{\"name\":\"cloud-login\",\"provider\":\"agentbay\",\"reuse\":\"require\",\"copyCookieDomains\":[\"example.com\"]}'", "browser_launch --payload '{\"name\":\"cloud-tools\",\"provider\":\"agentbay\",\"copyCookieDomains\":\"mearl-services\"}'", "browser_launch --payload '{\"name\":\"account-a\",\"headless\":true,\"accountId\":12345}'", "browser_launch --payload '{\"name\":\"figma\",\"userAgentMode\":\"desktop\",\"copyCookieDomains\":[\"figma.com\"]}'", "browser_launch --payload '{\"name\":\"account-b\",\"query\":\"test_account\",\"url\":\"https://www.taobao.com\"}'", "browser_launch --payload '{\"name\":\"with-cookies\",\"copyCookieDomains\":[\"example.com\",\"sub.example.org\"]}'"];
|
|
460
|
+
readonly examples: ["browser_launch --payload '{\"name\":\"cloud-a\",\"provider\":\"agentbay\",\"userAgentMode\":\"desktop\",\"url\":\"https://example.com\"}'", "browser_launch --payload '{\"name\":\"cloud-public\",\"provider\":\"agentbay\",\"imageId\":\"browser_latest\",\"url\":\"https://example.com\"}'", "browser_launch --payload '{\"name\":\"cloud-login\",\"provider\":\"agentbay\",\"persistent\":true,\"copyCookieDomains\":[\"example.com\"],\"url\":\"https://example.com/account\"}'", "browser_launch --payload '{\"name\":\"cloud-login\",\"provider\":\"agentbay\",\"reuse\":\"require\",\"copyCookieDomains\":[\"example.com\"]}'", "browser_launch --browser '<本地浏览器 browserId>' --payload '{\"sessionId\":\"<AgentBay sessionId>\",\"copyCookieDomains\":[\"example.com\"]}'", "browser_launch --payload '{\"name\":\"cloud-tools\",\"provider\":\"agentbay\",\"copyCookieDomains\":\"mearl-services\"}'", "browser_launch --payload '{\"name\":\"cloud-tools\",\"provider\":\"agentbay\",\"copyCookieDomains\":{\"presets\":[\"mearl-services\"],\"domains\":[\"figma.com\"]}}'", "browser_launch --payload '{\"name\":\"account-a\",\"headless\":true,\"accountId\":12345}'", "browser_launch --payload '{\"name\":\"figma\",\"userAgentMode\":\"desktop\",\"copyCookieDomains\":[\"figma.com\"]}'", "browser_launch --payload '{\"name\":\"account-b\",\"query\":\"test_account\",\"url\":\"https://www.taobao.com\"}'", "browser_launch --payload '{\"name\":\"with-cookies\",\"copyCookieDomains\":[\"example.com\",\"sub.example.org\"]}'"];
|
|
458
461
|
}, {
|
|
459
462
|
readonly name: "browser_close";
|
|
460
463
|
readonly description: "关闭指定托管浏览器并按策略保留或清理本地 Profile / AgentBay Context";
|
package/dist/commands.js
CHANGED
|
@@ -290,19 +290,28 @@ export const COMMANDS = [
|
|
|
290
290
|
},
|
|
291
291
|
{
|
|
292
292
|
name: 'browser_release',
|
|
293
|
-
description: '
|
|
294
|
-
summary: '
|
|
293
|
+
description: 'sessionId 接入的 AgentBay 浏览器用它解除本机 attachment且不关闭云端 Session;其他浏览器仅按用户明确要求释放调试控制和会话级临时效果(含设备/时区模拟),不用于普通任务收尾',
|
|
294
|
+
summary: '释放浏览器控制或解除 Session attachment',
|
|
295
295
|
examples: ['browser_release', 'browser_release --browser chrome-a'],
|
|
296
296
|
},
|
|
297
297
|
{
|
|
298
298
|
name: 'browser_launch',
|
|
299
|
-
description: '启动本地 Chrome
|
|
299
|
+
description: '启动本地 Chrome、创建/复用同名 AgentBay 云浏览器,或用 sessionId 接入现有云端 Session',
|
|
300
300
|
params: [
|
|
301
|
-
{
|
|
301
|
+
{
|
|
302
|
+
name: 'name',
|
|
303
|
+
type: 'string',
|
|
304
|
+
description: '实例名称,创建浏览器时必填;按 sessionId 接入时可省略',
|
|
305
|
+
},
|
|
302
306
|
{
|
|
303
307
|
name: 'provider',
|
|
304
308
|
type: '"local" | "agentbay"',
|
|
305
|
-
description: '运行环境,默认 local
|
|
309
|
+
description: '运行环境,默认 local;传 sessionId 时自动使用 agentbay,否则 agentbay 从 AGENTBAY_KEY 创建云浏览器',
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'sessionId',
|
|
313
|
+
type: 'string',
|
|
314
|
+
description: '显式接入现有 AgentBay Session;不会创建、初始化或取得其生命周期所有权',
|
|
306
315
|
},
|
|
307
316
|
{
|
|
308
317
|
name: 'reuse',
|
|
@@ -346,8 +355,8 @@ export const COMMANDS = [
|
|
|
346
355
|
},
|
|
347
356
|
{
|
|
348
357
|
name: 'copyCookieDomains',
|
|
349
|
-
type: 'string[] | "mearl-services"',
|
|
350
|
-
description: '从控制浏览器复制 Cookie
|
|
358
|
+
type: 'string[] | "mearl-services" | { presets?: ["mearl-services"]; domains?: string[] }',
|
|
359
|
+
description: '从控制浏览器复制 Cookie:可传域名数组、Mearl 登录态预设,或组合预设与额外域名',
|
|
351
360
|
},
|
|
352
361
|
],
|
|
353
362
|
examples: [
|
|
@@ -355,7 +364,9 @@ export const COMMANDS = [
|
|
|
355
364
|
`browser_launch --payload '{"name":"cloud-public","provider":"agentbay","imageId":"browser_latest","url":"https://example.com"}'`,
|
|
356
365
|
`browser_launch --payload '{"name":"cloud-login","provider":"agentbay","persistent":true,"copyCookieDomains":["example.com"],"url":"https://example.com/account"}'`,
|
|
357
366
|
`browser_launch --payload '{"name":"cloud-login","provider":"agentbay","reuse":"require","copyCookieDomains":["example.com"]}'`,
|
|
367
|
+
`browser_launch --browser '<本地浏览器 browserId>' --payload '{"sessionId":"<AgentBay sessionId>","copyCookieDomains":["example.com"]}'`,
|
|
358
368
|
`browser_launch --payload '{"name":"cloud-tools","provider":"agentbay","copyCookieDomains":"mearl-services"}'`,
|
|
369
|
+
`browser_launch --payload '{"name":"cloud-tools","provider":"agentbay","copyCookieDomains":{"presets":["mearl-services"],"domains":["figma.com"]}}'`,
|
|
359
370
|
`browser_launch --payload '{"name":"account-a","headless":true,"accountId":12345}'`,
|
|
360
371
|
`browser_launch --payload '{"name":"figma","userAgentMode":"desktop","copyCookieDomains":["figma.com"]}'`,
|
|
361
372
|
`browser_launch --payload '{"name":"account-b","query":"test_account","url":"https://www.taobao.com"}'`,
|
|
@@ -157,6 +157,8 @@ export interface ManagedBrowserInfo extends BrowserInfoBase {
|
|
|
157
157
|
persistent: boolean;
|
|
158
158
|
cdpPort?: number;
|
|
159
159
|
agentbaySessionId?: string;
|
|
160
|
+
/** Whether this host owns the AgentBay session lifecycle or only attaches to it. */
|
|
161
|
+
agentbaySessionMode?: 'owned' | 'attached';
|
|
160
162
|
/** AgentBay image used to create the session. */
|
|
161
163
|
agentbayImageId?: string;
|
|
162
164
|
/** Persistent AgentBay browser context ID when enabled for this instance. */
|
|
@@ -199,12 +201,26 @@ export interface BrowserReleaseResult {
|
|
|
199
201
|
hadActiveControl: boolean;
|
|
200
202
|
transports: Array<'extension' | 'cdp'>;
|
|
201
203
|
affectedTabIds: number[];
|
|
204
|
+
/** True when browser_release also removed a sessionId-based local AgentBay attachment. */
|
|
205
|
+
detached?: boolean;
|
|
206
|
+
/** Attached sessions remain alive in AgentBay after the local attachment is released. */
|
|
207
|
+
sessionClosed?: boolean;
|
|
208
|
+
agentbaySessionId?: string;
|
|
202
209
|
failures?: BrowserReleaseFailure[];
|
|
203
210
|
}
|
|
211
|
+
export interface CookieCopyScope {
|
|
212
|
+
/** Maintained authentication-domain presets to include. */
|
|
213
|
+
presets?: Array<'mearl-services'>;
|
|
214
|
+
/** Additional domains or URLs to copy alongside the selected presets. */
|
|
215
|
+
domains?: string[];
|
|
216
|
+
}
|
|
204
217
|
export interface BrowserLaunchParams {
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
/** Required when creating a browser; optional when attaching by AgentBay sessionId. */
|
|
219
|
+
name?: string;
|
|
220
|
+
/** Browser runtime provider. Defaults to local Chrome unless sessionId is present. */
|
|
207
221
|
provider?: 'local' | 'agentbay';
|
|
222
|
+
/** Attach to an exact live AgentBay session without creating or owning it. */
|
|
223
|
+
sessionId?: string;
|
|
208
224
|
/** AgentBay running-session reuse policy. Defaults to never. */
|
|
209
225
|
reuse?: 'never' | 'prefer' | 'require';
|
|
210
226
|
/** AgentBay image alias or concrete image ID. Only supported by the AgentBay provider. */
|
|
@@ -221,8 +237,8 @@ export interface BrowserLaunchParams {
|
|
|
221
237
|
site?: string | number;
|
|
222
238
|
query?: string;
|
|
223
239
|
tabId?: number;
|
|
224
|
-
/** Copy explicit domains
|
|
225
|
-
copyCookieDomains?: string[] | 'mearl-services';
|
|
240
|
+
/** Copy explicit domains, the Mearl-service preset, or a combination of both. */
|
|
241
|
+
copyCookieDomains?: string[] | 'mearl-services' | CookieCopyScope;
|
|
226
242
|
}
|
|
227
243
|
export interface BrowserCloseParams {
|
|
228
244
|
browser: string;
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { UnifiedInvokeOptions } from './unified.js';
|
|
2
|
+
export { assertPluginName, findPluginEntry, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
|
|
3
|
+
export type PluginOptions = UnifiedInvokeOptions;
|
|
4
|
+
export interface PluginApp {
|
|
5
|
+
readonly options: Readonly<PluginOptions>;
|
|
6
|
+
command(signature: string, handler: PluginHandler, description?: string): void;
|
|
7
|
+
invoke(action: string, payload?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
|
|
8
|
+
progress(message: string): void;
|
|
9
|
+
}
|
|
10
|
+
export type PluginHandler = (input: Record<string, any>) => unknown | Promise<unknown>;
|
|
11
|
+
/** 注册和执行共享一个 app;仅 handler 执行期间开放浏览器调用。 */
|
|
12
|
+
export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invoke' | 'options' | 'progress'>, pluginName?: string): Promise<{
|
|
13
|
+
name: string | undefined;
|
|
14
|
+
version: string | undefined;
|
|
15
|
+
help(namespace: string, name?: string): string;
|
|
16
|
+
run(positionals: string[], payload?: unknown): Promise<unknown>;
|
|
17
|
+
}>;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { PluginError } from './pluginRegistry.js';
|
|
6
|
+
export { assertPluginName, findPluginEntry, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
|
|
7
|
+
function readPluginVersion(entry) {
|
|
8
|
+
try {
|
|
9
|
+
const content = readFileSync(resolve(dirname(entry), '..', 'SKILL.md'), 'utf8');
|
|
10
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
|
|
11
|
+
const versionLine = frontmatter?.split(/\r?\n/).find(line => /^version:\s*/.test(line));
|
|
12
|
+
return versionLine?.match(/^version:\s*["']?([^"'#\s]+)["']?\s*$/)?.[1];
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isObject(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function missingPluginEntryMessage(entry, pluginName) {
|
|
22
|
+
const name = pluginName ?? '<name>';
|
|
23
|
+
return [
|
|
24
|
+
'插件入口已失效',
|
|
25
|
+
'',
|
|
26
|
+
`插件:${name}`,
|
|
27
|
+
`入口:${entry}`,
|
|
28
|
+
'',
|
|
29
|
+
`卸载命令:npx @mearl/setup plugin uninstall ${name}`,
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
32
|
+
/** 注册和执行共享一个 app;仅 handler 执行期间开放浏览器调用。 */
|
|
33
|
+
export async function loadPlugin(entry, context, pluginName) {
|
|
34
|
+
try {
|
|
35
|
+
if (!statSync(entry).isFile())
|
|
36
|
+
throw new Error('not a file');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new PluginError('PLUGIN_ENTRY_MISSING', missingPluginEntryMessage(entry, pluginName));
|
|
40
|
+
}
|
|
41
|
+
const commands = new Map();
|
|
42
|
+
let executing = false;
|
|
43
|
+
let registering = true;
|
|
44
|
+
let name;
|
|
45
|
+
const app = Object.freeze({
|
|
46
|
+
options: Object.freeze({ ...context.options }),
|
|
47
|
+
command(signature, handler, description = '') {
|
|
48
|
+
if (!registering)
|
|
49
|
+
throw new Error('命令只能在入口注册阶段声明');
|
|
50
|
+
const [name, ...args] = signature.trim().split(/\s+/);
|
|
51
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name) || commands.has(name) || typeof handler !== 'function') {
|
|
52
|
+
throw new Error(`无效或重复的命令: ${signature}`);
|
|
53
|
+
}
|
|
54
|
+
const command = new Command(name).description(description);
|
|
55
|
+
for (const argument of args) {
|
|
56
|
+
if (!/^(?:<[a-zA-Z][\w-]*(?:\.\.\.)?>|\[[a-zA-Z][\w-]*(?:\.\.\.)?\])$/.test(argument)) {
|
|
57
|
+
throw new Error(`无效的位置参数: ${argument}`);
|
|
58
|
+
}
|
|
59
|
+
command.argument(argument);
|
|
60
|
+
}
|
|
61
|
+
const names = command.registeredArguments.map(argument => argument.name());
|
|
62
|
+
if (new Set(names).size !== names.length)
|
|
63
|
+
throw new Error(`参数名称重复: ${signature}`);
|
|
64
|
+
commands.set(name, { command, signature, handler });
|
|
65
|
+
},
|
|
66
|
+
invoke(action, payload = {}, options = {}) {
|
|
67
|
+
if (!executing)
|
|
68
|
+
throw new Error('app.invoke 只能在命令执行期间调用');
|
|
69
|
+
return context.invoke(action, payload, options);
|
|
70
|
+
},
|
|
71
|
+
progress(message) {
|
|
72
|
+
if (!executing)
|
|
73
|
+
throw new Error('app.progress 只能在命令执行期间调用');
|
|
74
|
+
if (typeof message !== 'string' || !message)
|
|
75
|
+
throw new Error('app.progress 需要非空字符串');
|
|
76
|
+
context.progress(message);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const module = await import(pathToFileURL(entry).href);
|
|
81
|
+
if (module.name !== undefined && typeof module.name !== 'string')
|
|
82
|
+
throw new Error('入口的 name 导出必须为字符串');
|
|
83
|
+
name = module.name;
|
|
84
|
+
if (typeof module.default !== 'function')
|
|
85
|
+
throw new Error('入口必须默认导出 register(app) 函数');
|
|
86
|
+
await module.default(app);
|
|
87
|
+
if (commands.size === 0)
|
|
88
|
+
throw new Error('入口未注册任何命令');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
throw new PluginError('PLUGIN_LOAD_FAILED', `${entry}: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
registering = false;
|
|
95
|
+
}
|
|
96
|
+
const find = (name) => {
|
|
97
|
+
const command = commands.get(name);
|
|
98
|
+
if (!command)
|
|
99
|
+
throw new PluginError('PLUGIN_COMMAND_NOT_FOUND', `未知子命令: ${name}`);
|
|
100
|
+
return command;
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
version: readPluginVersion(entry),
|
|
105
|
+
help(namespace, name) {
|
|
106
|
+
const selected = name ? [find(name)] : [...commands.values()];
|
|
107
|
+
const example = selected[0].command;
|
|
108
|
+
const input = Object.fromEntries(example.registeredArguments.map(argument => [
|
|
109
|
+
argument.name(),
|
|
110
|
+
argument.variadic ? [`<${argument.name()}>`] : `<${argument.name()}>`,
|
|
111
|
+
]));
|
|
112
|
+
return [
|
|
113
|
+
...selected.map(({ command, signature }) => ` ${namespace} ${signature} ${command.description()}`),
|
|
114
|
+
'',
|
|
115
|
+
` ${namespace} --payload '${JSON.stringify({ command: example.name(), input })}'`,
|
|
116
|
+
'',
|
|
117
|
+
'通用选项: --browser --connector --local --cdp --server --timeout --compact --output',
|
|
118
|
+
'插件选项: --verbose; -h, --help; -v, --version',
|
|
119
|
+
'JSON input 直接传给 handler;业务字段与校验规则见对应 skill。',
|
|
120
|
+
].join('\n');
|
|
121
|
+
},
|
|
122
|
+
async run(positionals, payload) {
|
|
123
|
+
if (payload !== undefined && positionals.length > 0) {
|
|
124
|
+
throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
|
|
125
|
+
}
|
|
126
|
+
if (payload !== undefined &&
|
|
127
|
+
(!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
|
|
128
|
+
throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
|
|
129
|
+
}
|
|
130
|
+
const envelope = payload;
|
|
131
|
+
const { command, handler } = find(envelope?.command ?? positionals[0] ?? '');
|
|
132
|
+
const input = envelope
|
|
133
|
+
? { ...envelope.input }
|
|
134
|
+
: Object.fromEntries(command.registeredArguments
|
|
135
|
+
.map((argument, index) => [
|
|
136
|
+
argument.name(),
|
|
137
|
+
argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
|
|
138
|
+
])
|
|
139
|
+
.filter(([, value]) => value !== undefined));
|
|
140
|
+
if (!envelope &&
|
|
141
|
+
!command.registeredArguments.some(argument => argument.variadic) &&
|
|
142
|
+
positionals.length - 1 > command.registeredArguments.length) {
|
|
143
|
+
throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数过多');
|
|
144
|
+
}
|
|
145
|
+
for (const argument of command.registeredArguments) {
|
|
146
|
+
const value = Object.hasOwn(input, argument.name()) ? input[argument.name()] : undefined;
|
|
147
|
+
if (argument.required &&
|
|
148
|
+
(value === undefined ||
|
|
149
|
+
value === null ||
|
|
150
|
+
value === '' ||
|
|
151
|
+
(Array.isArray(value) && value.length === 0))) {
|
|
152
|
+
throw new PluginError('PLUGIN_INVALID_INPUT', `缺少必填参数: ${argument.name()}`);
|
|
153
|
+
}
|
|
154
|
+
if (argument.variadic && value !== undefined && !Array.isArray(value)) {
|
|
155
|
+
throw new PluginError('PLUGIN_INVALID_INPUT', `${argument.name()} 必须为数组`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
executing = true;
|
|
159
|
+
try {
|
|
160
|
+
return await handler(input);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
executing = false;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class PluginError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string, message: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function pluginDirectory(): string;
|
|
6
|
+
export declare function assertPluginName(name: string): void;
|
|
7
|
+
export declare function resolvePluginEntry(target: string): string;
|
|
8
|
+
export declare function registerPlugin(name: string, target: string): string;
|
|
9
|
+
export declare function unregisterPlugin(name: string): boolean;
|
|
10
|
+
export declare function findPluginEntry(name: string): string | undefined;
|
|
11
|
+
export declare function listPluginNames(): string[];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { COMMAND_MAP } from './commands.js';
|
|
6
|
+
const RESERVED_NAMES = new Set([
|
|
7
|
+
'check',
|
|
8
|
+
'help',
|
|
9
|
+
'connector_list',
|
|
10
|
+
'capture_checkpoint',
|
|
11
|
+
'plugin',
|
|
12
|
+
'plugins',
|
|
13
|
+
]);
|
|
14
|
+
export class PluginError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(`${code}: ${message}`);
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function pluginDirectory() {
|
|
22
|
+
return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
|
|
23
|
+
}
|
|
24
|
+
export function assertPluginName(name) {
|
|
25
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name) || RESERVED_NAMES.has(name) || COMMAND_MAP.has(name)) {
|
|
26
|
+
throw new PluginError('PLUGIN_INVALID_NAME', `不可用的插件名称: ${name}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function resolvePluginEntry(target) {
|
|
30
|
+
const absolute = resolve(target);
|
|
31
|
+
try {
|
|
32
|
+
const entry = statSync(absolute).isDirectory()
|
|
33
|
+
? join(absolute, 'scripts', 'main.mjs')
|
|
34
|
+
: absolute;
|
|
35
|
+
if (!statSync(entry).isFile())
|
|
36
|
+
throw new Error('not a file');
|
|
37
|
+
return entry;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new PluginError('PLUGIN_ENTRY_MISSING', `入口不存在: ${absolute}(目录默认使用 scripts/main.mjs)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function registerPlugin(name, target) {
|
|
44
|
+
assertPluginName(name);
|
|
45
|
+
const entry = resolvePluginEntry(target);
|
|
46
|
+
const directory = pluginDirectory();
|
|
47
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
48
|
+
const file = join(directory, `${name}.json`);
|
|
49
|
+
// 每个插件独立保存,多个名称并发注册不会覆盖彼此;同名注册原子替换绑定。
|
|
50
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(temporary, `${JSON.stringify({ entry })}\n`, { mode: 0o600, flag: 'wx' });
|
|
53
|
+
renameSync(temporary, file);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(temporary, { force: true });
|
|
57
|
+
}
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
export function unregisterPlugin(name) {
|
|
61
|
+
assertPluginName(name);
|
|
62
|
+
const file = join(pluginDirectory(), `${name}.json`);
|
|
63
|
+
try {
|
|
64
|
+
rmSync(file);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT')
|
|
69
|
+
return false;
|
|
70
|
+
throw new PluginError('PLUGIN_UNINSTALL_FAILED', `无法移除插件注册: ${file}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function findPluginEntry(name) {
|
|
74
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name))
|
|
75
|
+
return undefined;
|
|
76
|
+
const file = join(pluginDirectory(), `${name}.json`);
|
|
77
|
+
let content;
|
|
78
|
+
try {
|
|
79
|
+
content = readFileSync(file, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT')
|
|
83
|
+
return undefined;
|
|
84
|
+
throw new PluginError('PLUGIN_REGISTRY_INVALID', `无法读取插件记录: ${file}`);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const record = JSON.parse(content);
|
|
88
|
+
if (typeof record?.entry !== 'string' || !isAbsolute(record.entry))
|
|
89
|
+
throw new Error('invalid entry');
|
|
90
|
+
return record.entry;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new PluginError('PLUGIN_REGISTRY_INVALID', `无效的插件记录: ${file}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function listPluginNames() {
|
|
97
|
+
try {
|
|
98
|
+
return readdirSync(pluginDirectory())
|
|
99
|
+
.filter(file => /^[a-z][a-z0-9-]*\.json$/.test(file))
|
|
100
|
+
.map(file => file.slice(0, -5))
|
|
101
|
+
.sort();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === 'ENOENT')
|
|
105
|
+
return [];
|
|
106
|
+
throw new PluginError('PLUGIN_REGISTRY_INVALID', '无法读取插件目录');
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/socket.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
14
15
|
import { resolveFileRefs } from './fileRefs.js';
|
|
15
16
|
import { listLiveBrowsers, findBrowser, describeBrowser } from './browserRegistry.js';
|
|
16
17
|
import { resolveActionTimeoutSec } from './actionTimeouts.js';
|
|
@@ -255,6 +256,7 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSour
|
|
|
255
256
|
const socket = net.createConnection(socketPath);
|
|
256
257
|
let settled = false;
|
|
257
258
|
let buffer = '';
|
|
259
|
+
const decoder = new StringDecoder('utf8');
|
|
258
260
|
const settle = (error, result) => {
|
|
259
261
|
if (settled)
|
|
260
262
|
return;
|
|
@@ -283,7 +285,7 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSour
|
|
|
283
285
|
socket.write(JSON.stringify(message) + '\n');
|
|
284
286
|
});
|
|
285
287
|
socket.on('data', chunk => {
|
|
286
|
-
buffer +=
|
|
288
|
+
buffer += decoder.write(chunk);
|
|
287
289
|
if (Buffer.byteLength(buffer, 'utf-8') > MAX_SOCKET_BUFFER_SIZE) {
|
|
288
290
|
settle(new Error(`Socket 响应超过 ${MAX_SOCKET_BUFFER_SIZE} 字节限制`));
|
|
289
291
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Unified Mearl SDK & CLI for local and remote browsers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"./cli": {
|
|
30
30
|
"types": "./dist/cli.d.ts",
|
|
31
31
|
"import": "./dist/cli.js"
|
|
32
|
+
},
|
|
33
|
+
"./plugin": {
|
|
34
|
+
"types": "./dist/plugin.d.ts",
|
|
35
|
+
"import": "./dist/plugin.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"bin": {
|
|
@@ -55,7 +59,7 @@
|
|
|
55
59
|
"commander": "^15.0.0",
|
|
56
60
|
"fastest-levenshtein": "^1.0.16",
|
|
57
61
|
"ws": "^8.18.0",
|
|
58
|
-
"@mearl/cloud-types": "2.
|
|
62
|
+
"@mearl/cloud-types": "2.12.0"
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
61
65
|
"@types/node": "^24.9.1",
|