@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 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` 仅在用户明确要求结束调试、退出接管或清理会话资源时使用,不作为普通任务收尾。它会释放当前目标浏览器的 debugger/CDP 控制和会话级临时状态(包括设备/时区模拟),保留浏览器及全部标签页;后续浏览器操作可自动重新建立控制。
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` 只复用且不会意外创建计费会话。`copyCookieDomains` 传数组时把控制浏览器指定域的 Cookie 写入目标,传 `"mearl-services"` 时同步 Mearl 依赖平台登录态。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)。
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 = [`不支持的命令: ${input}`];
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.includes('--version') || args.includes('-v')) {
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
  }
@@ -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,或创建/显式复用 AgentBay 云浏览器,并通过 CDP 接入 Mearl";
394
+ readonly description: "启动本地 Chrome、创建/复用同名 AgentBay 云浏览器,或用 sessionId 接入现有云端 Session";
395
395
  readonly params: [{
396
396
  readonly name: "name";
397
397
  readonly type: "string";
398
- readonly required: true;
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;agentbay 从 AGENTBAY_KEY 创建云浏览器";
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:传域名数组,或传 mearl-services 同步 Mearl 依赖平台登录态";
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,或创建/显式复用 AgentBay 云浏览器,并通过 CDP 接入 Mearl',
299
+ description: '启动本地 Chrome、创建/复用同名 AgentBay 云浏览器,或用 sessionId 接入现有云端 Session',
300
300
  params: [
301
- { name: 'name', type: 'string', required: true, description: '实例名称,最多 64 个字符' },
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;agentbay 从 AGENTBAY_KEY 创建云浏览器',
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:传域名数组,或传 mearl-services 同步 Mearl 依赖平台登录态',
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
- name: string;
206
- /** Browser runtime provider. Defaults to local Chrome. */
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 or the fixed Mearl-service preset from the control browser. */
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;
@@ -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 += chunk.toString('utf-8');
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.10.0",
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.10.0"
62
+ "@mearl/cloud-types": "2.12.0"
59
63
  },
60
64
  "devDependencies": {
61
65
  "@types/node": "^24.9.1",