@mearl/client 2.15.3 → 2.16.1

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
@@ -143,19 +143,21 @@ mearl yuque --help
143
143
  mearl yuque --version
144
144
  ```
145
145
 
146
- 目录默认使用 `scripts/main.mjs`。入口默认导出 `register(app)`,可导出简短的 `description`
147
- 显示在 `mearl` 插件列表中,并通过 `app.command(signature, handler, description?)`
148
- 注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
146
+ 目录默认使用 `scripts/main.mjs` 或 `scripts/main.py`;两者同时存在时使用 JavaScript 入口。JavaScript 入口默认导出 `register(app)`,Python 入口导出 `register(app)` 函数;两种入口都可声明简短的 `name` 和 `description`,并通过 `app.command(signature, handler, description?)`
147
+ 注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。Python 的 `app.invoke` 是同步调用,handler 可以是普通函数或 `async def`。Python 插件要求 `python3 >= 3.9`,可用 `MEARL_PYTHON` 指定解释器路径;Mearl 不安装 Python 或执行 `pip install`。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
149
148
  `--verbose` 时才把进度写入 stderr。handler 返回文本或 JSON,宿主统一处理 `--output`、连接和错误。写命令在用户调用后直接执行,业务模块负责目标、状态、幂等和结果校验。
150
- 自动注册默认以 skill 安装目录名作为命令名,也可在入口导出 `name` 指定短名称。
149
+ 自动注册默认以 skill 安装目录名作为命令名,也可在入口声明 `name` 指定短名称。
151
150
  setup 会补齐缺失或低于自身版本的 client、native-host;安装参数透传给 Ali Skills,并固定全局共享安装。
152
151
 
153
152
  需要单独检查插件时运行 `mearl <name> --version`。命令会解析注册记录并加载入口,但不连接浏览器;
154
153
  退出码为 0 表示插件已注册且入口可加载,stdout 返回同目录 `SKILL.md` 的版本,缺少版本元数据时返回 `unknown`。
155
154
  未注册、入口丢失或加载失败会以退出码 2 和对应的 `PLUGIN_*` 错误结束。普通业务调用无需预先检查,直接处理同样的错误即可。
156
155
 
157
- 插件接受位置参数或包含 command/input 的 `--payload`,两种形式不能混用。JSON 保留业务字段类型;正文等文件输入
158
- 按具体 skill 的字段约定处理。插件不使用上表中内置 action 的 `--payload-file` 入口。
156
+ 插件接受位置参数、字符串业务选项或包含 command/input 的 `--payload`。业务选项推荐写成
157
+ `--name=value`,也接受 `--name value`,字段名与 handler 的 input 一致且不依赖位置顺序;同一字段不能同时
158
+ 使用位置参数和业务选项,`--payload` 不能与前两种形式混用。JSON 保留业务字段类型;正文等文件输入按具体
159
+ skill 的字段约定处理。`browser`、`timeout`、`output` 等宿主选项保留原有含义;同名业务字段以及数字、
160
+ 布尔值、对象和数组通过 `--payload` 传入。插件不使用上表中内置 action 的 `--payload-file` 入口。
159
161
 
160
162
  注册后的插件也可从 SDK 调用;实例方法 `client.invokePlugin` 复用既有连接和默认浏览器:
161
163
 
@@ -181,8 +183,11 @@ MCP Server 通过固定工具 `mearl_plugin` 接受同样的 `plugin`、`command
181
183
 
182
184
  需要 BUC 身份的内部 HTTP 接口通过官方 `buc` 插件调用。CLI 使用 `mearl buc request`,业务插件使用
183
185
  `app.invoke('buc', { command: 'request', input })`;当前身份和人员资料分别使用 `whoami` 与 `profile`
184
- 子命令。BUC 插件按请求创建一次性凭据,只允许访问 HTTPS `alibaba-inc.com` 域名,过滤认证字段与
185
- Cookie,并始终在运行 CLI、SDK 或 MCP Server 的机器上执行,不随浏览器或 connector 转发。
186
+ 子命令。BUC 插件按请求创建一次性凭据,只接受无 URL 凭据的 HTTPS 地址,过滤认证字段与 Cookie;
187
+ 调用方须按已验证的业务契约确认目标服务可信。请求始终在运行 CLI、SDK 或 MCP Server 的机器上执行,
188
+ 不随浏览器或 connector 转发。
189
+ `buc request` 支持与 `send_request` 同结构的 `formData` 和 `files`;`files[].filePath` 从该插件执行
190
+ 宿主读取,而不是浏览器 native-host,无法引用宿主路径时使用 Base64 内容与 `filename`。
186
191
 
187
192
  ## 支持的操作
188
193
 
package/dist/cli.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface ParsedArgs {
17
17
  timeoutSec: number;
18
18
  timeoutOption?: string;
19
19
  pluginArgs?: string[];
20
+ pluginInput?: Record<string, unknown>;
20
21
  help?: boolean;
21
22
  version?: boolean;
22
23
  verbose?: boolean;
package/dist/cli.js CHANGED
@@ -70,6 +70,39 @@ const SUGGESTABLE_COMMANDS = [
70
70
  ...OFFICIAL_PLUGIN_COMMANDS,
71
71
  ];
72
72
  const ADVANCED_BUILTIN_COMMANDS = new Set(['connector_list']);
73
+ const PLUGIN_HOST_OPTIONS = new Set([
74
+ '--browser',
75
+ '--cdp',
76
+ '--compact',
77
+ '--connector',
78
+ '--help',
79
+ '--json',
80
+ '--local',
81
+ '--output',
82
+ '--payload',
83
+ '--payload-file',
84
+ '--server',
85
+ '--timeout',
86
+ '--verbose',
87
+ '--version',
88
+ ]);
89
+ function findPluginInputOptionNames(args) {
90
+ const names = new Set();
91
+ for (const argument of args) {
92
+ if (!argument.startsWith('--') || argument === '--')
93
+ continue;
94
+ const equals = argument.indexOf('=');
95
+ const option = equals === -1 ? argument : argument.slice(0, equals);
96
+ if (PLUGIN_HOST_OPTIONS.has(option))
97
+ continue;
98
+ const name = option.slice(2);
99
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
100
+ throw new Error(`无效的插件业务选项: ${option}`);
101
+ }
102
+ names.add(name);
103
+ }
104
+ return [...names];
105
+ }
73
106
  function printUsage(commandName, version) {
74
107
  const lines = [
75
108
  `${commandName} v${version}`,
@@ -355,6 +388,7 @@ function actionInvocationFailed(action, result) {
355
388
  : false;
356
389
  }
357
390
  export function parseClientArgs(args, plugin = false) {
391
+ const pluginInputOptionNames = plugin ? findPluginInputOptionNames(args) : [];
358
392
  const program = new Command()
359
393
  .name('mearl')
360
394
  .helpOption(false)
@@ -379,6 +413,8 @@ export function parseClientArgs(args, plugin = false) {
379
413
  .option('-h, --help')
380
414
  .option('-v, --version')
381
415
  .option('--verbose');
416
+ for (const name of pluginInputOptionNames)
417
+ program.option(`--${name} <value>`);
382
418
  try {
383
419
  program.parse(['node', 'mearl', ...args]);
384
420
  }
@@ -393,6 +429,12 @@ export function parseClientArgs(args, plugin = false) {
393
429
  if (plugin && options.payloadFile !== undefined) {
394
430
  throw new Error('插件使用 --payload 传入 command 和 input;正文文件可由业务字段引用');
395
431
  }
432
+ const pluginInput = Object.fromEntries(pluginInputOptionNames
433
+ .filter(name => Object.hasOwn(options, name))
434
+ .map(name => [name, options[name]]));
435
+ if (plugin && options.payload !== undefined && Object.keys(pluginInput).length > 0) {
436
+ throw new Error('插件业务选项不能与 --payload 同时使用');
437
+ }
396
438
  const json = options.json === true;
397
439
  if (json && action !== 'check')
398
440
  throw new Error('--json 仅适用于 check');
@@ -420,6 +462,7 @@ export function parseClientArgs(args, plugin = false) {
420
462
  ...(plugin
421
463
  ? {
422
464
  pluginArgs: program.processedArgs[1],
465
+ ...(Object.keys(pluginInput).length > 0 ? { pluginInput } : {}),
423
466
  help: options.help === true,
424
467
  version: options.version === true,
425
468
  verbose: options.verbose === true,
@@ -471,7 +514,7 @@ async function runPluginCli(args, commandName) {
471
514
  console.error(plugin.help(`${commandName} ${parsed.action}`, parsed.pluginArgs?.[0]));
472
515
  return 0;
473
516
  }
474
- const result = await plugin.run(parsed.pluginArgs ?? [], parsed.hasPayload ? parsed.payload : undefined);
517
+ const result = await plugin.run(parsed.pluginArgs ?? [], parsed.hasPayload ? parsed.payload : undefined, parsed.pluginInput);
475
518
  if (result !== undefined) {
476
519
  if (parsed.outputPath)
477
520
  writeOutput('', result, parsed.outputPath);
package/dist/plugin.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type OfficialPluginInstaller } from './officialPluginInstaller.js';
2
2
  import type { UnifiedInvokeOptions } from './unified.js';
3
- export { assertPluginName, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, type PluginRegistration, type PluginRegistrationOptions, } from './pluginRegistry.js';
3
+ export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, type PluginRegistration, type PluginRegistrationOptions, } from './pluginRegistry.js';
4
4
  export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, type OfficialPluginDefinition, type OfficialPluginName, } from './officialPluginCatalog.js';
5
5
  export type PluginOptions = UnifiedInvokeOptions;
6
6
  export interface PluginInvocation {
@@ -21,19 +21,19 @@ export interface PluginApp {
21
21
  progress(message: string): void;
22
22
  }
23
23
  export type PluginHandler = (input: Record<string, any>) => unknown | Promise<unknown>;
24
- /** 注册和执行共享一个 app;仅 handler 执行期间开放浏览器调用。 */
24
+ /** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
25
25
  export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invoke' | 'options' | 'progress'>, pluginName?: string): Promise<{
26
26
  name: string | undefined;
27
27
  description: string | undefined;
28
28
  version: string | undefined;
29
29
  help(namespace: string, name?: string): string;
30
- run(positionals: string[], payload?: unknown): Promise<unknown>;
30
+ run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
31
31
  }>;
32
32
  export declare function loadRegisteredPlugin(name: string, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<{
33
33
  name: string | undefined;
34
34
  description: string | undefined;
35
35
  version: string | undefined;
36
36
  help(namespace: string, name?: string): string;
37
- run(positionals: string[], payload?: unknown): Promise<unknown>;
37
+ run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
38
38
  }>;
39
39
  export declare function invokeRegisteredPlugin(name: string, invocation: PluginInvocation, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<unknown>;
package/dist/plugin.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
- import { dirname, resolve } from 'node:path';
2
+ import { dirname, extname, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { Command } from 'commander';
5
5
  import { COMMAND_MAP } from './commands.js';
6
6
  import { findOfficialPlugin } from './officialPluginCatalog.js';
7
7
  import { ensureOfficialPluginRegistered, } from './officialPluginInstaller.js';
8
8
  import { findPluginRegistration, PluginError } from './pluginRegistry.js';
9
- export { assertPluginName, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
9
+ import { describePythonPlugin, runPythonPluginCommand } from './pythonPlugin.js';
10
+ export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
10
11
  export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, } from './officialPluginCatalog.js';
11
12
  function readPluginVersion(entry) {
12
13
  try {
@@ -36,7 +37,7 @@ function missingPluginEntryMessage(entry, pluginName) {
36
37
  `卸载命令:npx @mearl/setup plugin uninstall ${name}`,
37
38
  ].join('\n');
38
39
  }
39
- /** 注册和执行共享一个 app;仅 handler 执行期间开放浏览器调用。 */
40
+ /** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
40
41
  export async function loadPlugin(entry, context, pluginName) {
41
42
  let entryVersion;
42
43
  try {
@@ -88,18 +89,29 @@ export async function loadPlugin(entry, context, pluginName) {
88
89
  },
89
90
  });
90
91
  try {
91
- const moduleUrl = pathToFileURL(entry);
92
- moduleUrl.searchParams.set('mearl-version', entryVersion);
93
- const module = await import(moduleUrl.href);
94
- if (module.name !== undefined && typeof module.name !== 'string')
95
- throw new Error('入口的 name 导出必须为字符串');
96
- name = module.name;
97
- if (module.description !== undefined && typeof module.description !== 'string')
98
- throw new Error('入口的 description 导出必须为字符串');
99
- description = module.description;
100
- if (typeof module.default !== 'function')
101
- throw new Error('入口必须默认导出 register(app) 函数');
102
- await module.default(app);
92
+ if (extname(entry).toLowerCase() === '.py') {
93
+ const descriptor = await describePythonPlugin(entry, app.options);
94
+ name = descriptor.name;
95
+ description = descriptor.description;
96
+ for (const command of descriptor.commands) {
97
+ const commandName = command.signature.trim().split(/\s+/)[0];
98
+ app.command(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
99
+ }
100
+ }
101
+ else {
102
+ const moduleUrl = pathToFileURL(entry);
103
+ moduleUrl.searchParams.set('mearl-version', entryVersion);
104
+ const module = await import(moduleUrl.href);
105
+ if (module.name !== undefined && typeof module.name !== 'string')
106
+ throw new Error('入口的 name 导出必须为字符串');
107
+ name = module.name;
108
+ if (module.description !== undefined && typeof module.description !== 'string')
109
+ throw new Error('入口的 description 导出必须为字符串');
110
+ description = module.description;
111
+ if (typeof module.default !== 'function')
112
+ throw new Error('入口必须默认导出 register(app) 函数');
113
+ await module.default(app);
114
+ }
103
115
  if (commands.size === 0)
104
116
  throw new Error('入口未注册任何命令');
105
117
  }
@@ -133,21 +145,24 @@ export async function loadPlugin(entry, context, pluginName) {
133
145
  '',
134
146
  '常用选项: --browser --timeout --compact --output',
135
147
  '连接或传输排障运行 mearl check --help。',
136
- '插件选项: --verbose; -h, --help; -v, --version',
137
- 'JSON input 直接传给 handler;业务字段与校验规则见对应 skill。',
148
+ '插件选项: --name=value 传入字符串业务字段;--verbose; -h, --help; -v, --version',
149
+ '同名宿主选项及非字符串字段使用 --payload;业务字段与校验规则见对应 skill。',
138
150
  ].join('\n');
139
151
  },
140
- async run(positionals, payload) {
152
+ async run(positionals, payload, namedInput = {}) {
141
153
  if (payload !== undefined && positionals.length > 0) {
142
154
  throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
143
155
  }
156
+ if (payload !== undefined && Object.keys(namedInput).length > 0) {
157
+ throw new PluginError('PLUGIN_INVALID_INPUT', '插件业务选项与 --payload 调用不能混用');
158
+ }
144
159
  if (payload !== undefined &&
145
160
  (!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
146
161
  throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
147
162
  }
148
163
  const envelope = payload;
149
164
  const { command, handler } = find(envelope?.command ?? positionals[0] ?? '');
150
- const input = envelope
165
+ const positionalInput = envelope
151
166
  ? { ...envelope.input }
152
167
  : Object.fromEntries(command.registeredArguments
153
168
  .map((argument, index) => [
@@ -155,6 +170,12 @@ export async function loadPlugin(entry, context, pluginName) {
155
170
  argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
156
171
  ])
157
172
  .filter(([, value]) => value !== undefined));
173
+ for (const name of Object.keys(namedInput)) {
174
+ if (Object.hasOwn(positionalInput, name)) {
175
+ throw new PluginError('PLUGIN_INVALID_INPUT', `参数不能重复传入: ${name}`);
176
+ }
177
+ }
178
+ const input = { ...positionalInput, ...namedInput };
158
179
  if (!envelope &&
159
180
  !command.registeredArguments.some(argument => argument.variadic) &&
160
181
  positionals.length - 1 > command.registeredArguments.length) {
@@ -12,6 +12,7 @@ export interface PluginRegistrationOptions {
12
12
  }
13
13
  export declare function pluginDirectory(): string;
14
14
  export declare function assertPluginName(name: string): void;
15
+ export declare function findPluginDirectoryEntry(directory: string): string | undefined;
15
16
  export declare function resolvePluginEntry(target: string): string;
16
17
  export declare function registerPlugin(name: string, target: string, description?: string, options?: PluginRegistrationOptions): string;
17
18
  export declare function unregisterPlugin(name: string): boolean;
@@ -26,18 +26,31 @@ export function assertPluginName(name) {
26
26
  throw new PluginError('PLUGIN_INVALID_NAME', `不可用的插件名称: ${name}`);
27
27
  }
28
28
  }
29
+ export function findPluginDirectoryEntry(directory) {
30
+ for (const filename of ['main.mjs', 'main.py']) {
31
+ const entry = join(directory, 'scripts', filename);
32
+ try {
33
+ if (statSync(entry).isFile())
34
+ return entry;
35
+ }
36
+ catch {
37
+ // 没有约定入口的目录是普通 skill,调用方据此跳过自动注册。
38
+ }
39
+ }
40
+ return undefined;
41
+ }
29
42
  export function resolvePluginEntry(target) {
30
43
  const absolute = resolve(target);
31
44
  try {
32
- const entry = statSync(absolute).isDirectory()
33
- ? join(absolute, 'scripts', 'main.mjs')
34
- : absolute;
45
+ const entry = statSync(absolute).isDirectory() ? findPluginDirectoryEntry(absolute) : absolute;
46
+ if (!entry)
47
+ throw new Error('not a file');
35
48
  if (!statSync(entry).isFile())
36
49
  throw new Error('not a file');
37
50
  return entry;
38
51
  }
39
52
  catch {
40
- throw new PluginError('PLUGIN_ENTRY_MISSING', `入口不存在: ${absolute}(目录默认使用 scripts/main.mjs)`);
53
+ throw new PluginError('PLUGIN_ENTRY_MISSING', `入口不存在: ${absolute}(目录默认使用 scripts/main.mjs 或 scripts/main.py)`);
41
54
  }
42
55
  }
43
56
  export function registerPlugin(name, target, description, options = {}) {
@@ -0,0 +1,17 @@
1
+ import type { PluginOptions } from './plugin.js';
2
+ export interface PythonPluginCommand {
3
+ description: string;
4
+ signature: string;
5
+ }
6
+ export interface PythonPluginDescriptor {
7
+ commands: PythonPluginCommand[];
8
+ description?: string;
9
+ name?: string;
10
+ }
11
+ export interface PythonPluginApp {
12
+ readonly options: Readonly<PluginOptions>;
13
+ invoke(action: string, payload?: Record<string, any>, options?: PluginOptions): Promise<any>;
14
+ progress(message: string): void;
15
+ }
16
+ export declare function describePythonPlugin(entry: string, options: Readonly<PluginOptions>): Promise<PythonPluginDescriptor>;
17
+ export declare function runPythonPluginCommand(entry: string, command: string, input: Record<string, any>, app: PythonPluginApp): Promise<unknown>;
@@ -0,0 +1,403 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { PluginError } from './pluginRegistry.js';
4
+ const STDERR_LIMIT = 64 * 1024;
5
+ // Bootstrap 随 Node client 分发,Python 插件无需额外安装宿主 SDK。
6
+ const PYTHON_BOOTSTRAP = String.raw `
7
+ import asyncio
8
+ import importlib.util
9
+ import inspect
10
+ import json
11
+ import os
12
+ import sys
13
+ from types import MappingProxyType
14
+
15
+ _protocol_output = sys.stdout
16
+ sys.stdout = sys.stderr
17
+
18
+
19
+ def _send(message):
20
+ encoded = json.dumps(message, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
21
+ _protocol_output.write(encoded + "\n")
22
+ _protocol_output.flush()
23
+
24
+
25
+ def _receive():
26
+ line = sys.stdin.readline()
27
+ if not line:
28
+ raise RuntimeError("Mearl 宿主已断开")
29
+ message = json.loads(line)
30
+ if not isinstance(message, dict):
31
+ raise RuntimeError("Mearl 宿主发送了无效消息")
32
+ return message
33
+
34
+
35
+ def _resolve(value):
36
+ if inspect.isawaitable(value):
37
+ return asyncio.run(value)
38
+ return value
39
+
40
+
41
+ class _InvocationError(RuntimeError):
42
+ def __init__(self, message, code=None):
43
+ super().__init__(message)
44
+ self.code = code
45
+
46
+
47
+ class _App:
48
+ def __init__(self, options):
49
+ self.options = MappingProxyType(options)
50
+ self.commands = []
51
+ self.registering = True
52
+ self.executing = False
53
+ self.request_id = 0
54
+
55
+ def command(self, signature, handler=None, description=""):
56
+ if not self.registering:
57
+ raise RuntimeError("命令只能在入口注册阶段声明")
58
+ if not isinstance(signature, str):
59
+ raise TypeError("命令签名必须为字符串")
60
+ if not isinstance(description, str):
61
+ raise TypeError("命令 description 必须为字符串")
62
+
63
+ def add(candidate):
64
+ if not callable(candidate):
65
+ raise TypeError("命令 handler 必须可调用")
66
+ self.commands.append({
67
+ "signature": signature,
68
+ "description": description,
69
+ "handler": candidate,
70
+ })
71
+ return candidate
72
+
73
+ if handler is None:
74
+ return add
75
+ add(handler)
76
+ return None
77
+
78
+ def invoke(self, action, payload=None, options=None):
79
+ if not self.executing:
80
+ raise RuntimeError("app.invoke 只能在命令执行期间调用")
81
+ if not isinstance(action, str) or not action:
82
+ raise TypeError("app.invoke action 必须为非空字符串")
83
+ if payload is None:
84
+ payload = {}
85
+ if options is None:
86
+ options = {}
87
+ if not isinstance(payload, dict) or not isinstance(options, dict):
88
+ raise TypeError("app.invoke payload 和 options 必须为字典")
89
+ self.request_id += 1
90
+ request_id = self.request_id
91
+ _send({
92
+ "type": "invoke",
93
+ "id": request_id,
94
+ "action": action,
95
+ "payload": payload,
96
+ "options": options,
97
+ })
98
+ response = _receive()
99
+ if response.get("type") != "response" or response.get("id") != request_id:
100
+ raise RuntimeError("Mearl 宿主返回了无效的 invoke 响应")
101
+ error = response.get("error")
102
+ if isinstance(error, dict):
103
+ raise _InvocationError(str(error.get("message", "调用失败")), error.get("code"))
104
+ return response.get("result")
105
+
106
+ def progress(self, message):
107
+ if not self.executing:
108
+ raise RuntimeError("app.progress 只能在命令执行期间调用")
109
+ if not isinstance(message, str) or not message:
110
+ raise TypeError("app.progress 需要非空字符串")
111
+ _send({"type": "progress", "message": message})
112
+
113
+
114
+ def _error_message(error):
115
+ if isinstance(error, _InvocationError):
116
+ return str(error)
117
+ detail = str(error)
118
+ return type(error).__name__ + (": " + detail if detail else "")
119
+
120
+
121
+ phase = "load"
122
+ try:
123
+ if sys.version_info < (3, 9):
124
+ current = ".".join(str(part) for part in sys.version_info[:3])
125
+ raise RuntimeError("需要 Python 3.9 或更高版本,当前为 " + current)
126
+
127
+ initial = _receive()
128
+ mode = initial.get("mode")
129
+ options = initial.get("options", {})
130
+ if mode not in ("describe", "run") or not isinstance(options, dict):
131
+ raise RuntimeError("Mearl 宿主发送了无效的初始化消息")
132
+
133
+ entry = os.path.abspath(sys.argv[1])
134
+ entry_directory = os.path.dirname(entry)
135
+ if entry_directory not in sys.path:
136
+ sys.path.insert(0, entry_directory)
137
+ spec = importlib.util.spec_from_file_location("mearl_skill_plugin", entry)
138
+ if spec is None or spec.loader is None:
139
+ raise RuntimeError("无法加载 Python 插件入口")
140
+ module = importlib.util.module_from_spec(spec)
141
+ sys.modules[spec.name] = module
142
+ spec.loader.exec_module(module)
143
+
144
+ plugin_name = getattr(module, "name", None)
145
+ plugin_description = getattr(module, "description", None)
146
+ if plugin_name is not None and not isinstance(plugin_name, str):
147
+ raise TypeError("入口的 name 必须为字符串")
148
+ if plugin_description is not None and not isinstance(plugin_description, str):
149
+ raise TypeError("入口的 description 必须为字符串")
150
+ register = getattr(module, "register", None)
151
+ if not callable(register):
152
+ raise TypeError("入口必须导出 register(app) 函数")
153
+
154
+ app = _App(options)
155
+ _resolve(register(app))
156
+ app.registering = False
157
+ if not app.commands:
158
+ raise RuntimeError("入口未注册任何命令")
159
+
160
+ descriptor = {
161
+ "type": "ready",
162
+ "commands": [
163
+ {"signature": item["signature"], "description": item["description"]}
164
+ for item in app.commands
165
+ ],
166
+ }
167
+ if plugin_name is not None:
168
+ descriptor["name"] = plugin_name
169
+ if plugin_description is not None:
170
+ descriptor["description"] = plugin_description
171
+ _send(descriptor)
172
+
173
+ if mode == "run":
174
+ acknowledgement = _receive()
175
+ if acknowledgement.get("type") != "ready_ack":
176
+ raise RuntimeError("Mearl 宿主未确认插件注册信息")
177
+ command_name = initial.get("command")
178
+ command_input = initial.get("input")
179
+ if not isinstance(command_name, str) or not isinstance(command_input, dict):
180
+ raise RuntimeError("Mearl 宿主发送了无效的命令")
181
+ selected = next(
182
+ (item for item in app.commands if item["signature"].strip().split()[0] == command_name),
183
+ None,
184
+ )
185
+ if selected is None:
186
+ raise RuntimeError("未知子命令: " + command_name)
187
+ phase = "run"
188
+ app.executing = True
189
+ try:
190
+ result = _resolve(selected["handler"](command_input))
191
+ finally:
192
+ app.executing = False
193
+ _send({"type": "result", "result": result})
194
+ except BaseException as error:
195
+ response = {"type": "error", "phase": phase, "message": _error_message(error)}
196
+ code = getattr(error, "code", None)
197
+ if isinstance(code, str):
198
+ response["code"] = code
199
+ try:
200
+ _send(response)
201
+ except BaseException as serialization_error:
202
+ _send({
203
+ "type": "error",
204
+ "phase": phase,
205
+ "message": "返回值无法 JSON 序列化: " + _error_message(serialization_error),
206
+ })
207
+ sys.exit(1)
208
+ `;
209
+ function pythonExecutable() {
210
+ return process.env.MEARL_PYTHON?.trim() || 'python3';
211
+ }
212
+ function isObject(value) {
213
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
214
+ }
215
+ function parseDescriptor(message) {
216
+ if (!Array.isArray(message.commands))
217
+ throw new Error('Python 插件未返回命令列表');
218
+ const commands = message.commands.map(value => {
219
+ if (!isObject(value) ||
220
+ typeof value.signature !== 'string' ||
221
+ typeof value.description !== 'string') {
222
+ throw new Error('Python 插件返回了无效的命令描述');
223
+ }
224
+ return { signature: value.signature, description: value.description };
225
+ });
226
+ if (message.name !== undefined && typeof message.name !== 'string') {
227
+ throw new Error('Python 插件返回了无效的 name');
228
+ }
229
+ if (message.description !== undefined && typeof message.description !== 'string') {
230
+ throw new Error('Python 插件返回了无效的 description');
231
+ }
232
+ return {
233
+ commands,
234
+ ...(typeof message.name === 'string' ? { name: message.name } : {}),
235
+ ...(typeof message.description === 'string' ? { description: message.description } : {}),
236
+ };
237
+ }
238
+ function bridgedError(message) {
239
+ const detail = typeof message.message === 'string' ? message.message : 'Python 插件执行失败';
240
+ if (typeof message.code === 'string' && /^PLUGIN_[A-Z0-9_]+$/.test(message.code)) {
241
+ return new PluginError(message.code, detail);
242
+ }
243
+ return new Error(detail);
244
+ }
245
+ function errorResponse(error) {
246
+ if (error instanceof PluginError) {
247
+ const prefix = `${error.code}: `;
248
+ return {
249
+ code: error.code,
250
+ message: error.message.startsWith(prefix)
251
+ ? error.message.slice(prefix.length)
252
+ : error.message,
253
+ };
254
+ }
255
+ return { message: error instanceof Error ? error.message : String(error) };
256
+ }
257
+ async function runPythonWorker(entry, initial, app) {
258
+ return new Promise((resolve, reject) => {
259
+ const executable = pythonExecutable();
260
+ const child = spawn(executable, ['-u', '-c', PYTHON_BOOTSTRAP, entry], {
261
+ stdio: ['pipe', 'pipe', 'pipe'],
262
+ windowsHide: true,
263
+ });
264
+ const lines = createInterface({ input: child.stdout });
265
+ let descriptor;
266
+ let outcome;
267
+ let protocolError;
268
+ let stderr = '';
269
+ const failProtocol = (error) => {
270
+ if (protocolError)
271
+ return;
272
+ protocolError = error instanceof Error ? error : new Error(String(error));
273
+ child.kill();
274
+ };
275
+ const write = (message) => {
276
+ try {
277
+ child.stdin.write(`${JSON.stringify(message)}\n`);
278
+ }
279
+ catch (error) {
280
+ failProtocol(error);
281
+ }
282
+ };
283
+ child.stderr.setEncoding('utf8');
284
+ child.stderr.on('data', chunk => {
285
+ stderr = `${stderr}${chunk}`.slice(-STDERR_LIMIT);
286
+ });
287
+ child.stdin.on('error', () => undefined);
288
+ child.once('error', error => {
289
+ if (error.code === 'ENOENT') {
290
+ reject(new Error(`未找到 ${executable},Python 插件需要 Python 3.9 或更高版本` +
291
+ '(可通过 MEARL_PYTHON 指定解释器)'));
292
+ }
293
+ else {
294
+ reject(error);
295
+ }
296
+ });
297
+ lines.on('line', line => {
298
+ let message;
299
+ try {
300
+ const value = JSON.parse(line);
301
+ if (!isObject(value) || typeof value.type !== 'string') {
302
+ throw new Error('消息不是对象');
303
+ }
304
+ message = value;
305
+ }
306
+ catch (error) {
307
+ failProtocol(new Error(`Python 插件输出了无效协议消息: ${line.slice(0, 200)}`, error instanceof Error ? { cause: error } : undefined));
308
+ return;
309
+ }
310
+ if (message.type === 'ready') {
311
+ try {
312
+ if (descriptor)
313
+ throw new Error('Python 插件重复发送 ready');
314
+ descriptor = parseDescriptor(message);
315
+ if (initial.mode === 'run')
316
+ write({ type: 'ready_ack' });
317
+ }
318
+ catch (error) {
319
+ failProtocol(error);
320
+ }
321
+ return;
322
+ }
323
+ if (message.type === 'progress') {
324
+ if (!app || typeof message.message !== 'string') {
325
+ failProtocol(new Error('Python 插件发送了无效的 progress 消息'));
326
+ return;
327
+ }
328
+ try {
329
+ app.progress(message.message);
330
+ }
331
+ catch (error) {
332
+ failProtocol(error);
333
+ }
334
+ return;
335
+ }
336
+ if (message.type === 'invoke') {
337
+ if (!app ||
338
+ typeof message.id !== 'number' ||
339
+ typeof message.action !== 'string' ||
340
+ !isObject(message.payload) ||
341
+ !isObject(message.options)) {
342
+ failProtocol(new Error('Python 插件发送了无效的 invoke 消息'));
343
+ return;
344
+ }
345
+ void app
346
+ .invoke(message.action, message.payload, message.options)
347
+ .then(result => {
348
+ write({
349
+ type: 'response',
350
+ id: message.id,
351
+ result: result === undefined ? null : result,
352
+ });
353
+ })
354
+ .catch(error => {
355
+ write({ type: 'response', id: message.id, error: errorResponse(error) });
356
+ });
357
+ return;
358
+ }
359
+ if (message.type === 'result') {
360
+ outcome = { hasResult: true, result: message.result };
361
+ return;
362
+ }
363
+ if (message.type === 'error') {
364
+ outcome = { error: bridgedError(message) };
365
+ return;
366
+ }
367
+ failProtocol(new Error(`Python 插件发送了未知消息类型: ${message.type}`));
368
+ });
369
+ child.once('close', code => {
370
+ lines.close();
371
+ if (protocolError) {
372
+ reject(protocolError);
373
+ return;
374
+ }
375
+ if (outcome?.error) {
376
+ reject(outcome.error);
377
+ return;
378
+ }
379
+ if (code !== 0) {
380
+ reject(new Error(stderr.trim() || `Python 插件进程异常退出${code === null ? '' : ` (exit ${code})`}`));
381
+ return;
382
+ }
383
+ if (!descriptor) {
384
+ reject(new Error('Python 插件未返回注册信息'));
385
+ return;
386
+ }
387
+ if (initial.mode === 'run' && !outcome?.hasResult) {
388
+ reject(new Error('Python 插件未返回执行结果'));
389
+ return;
390
+ }
391
+ resolve({ descriptor, ...(outcome?.hasResult ? { result: outcome.result } : {}) });
392
+ });
393
+ write(initial);
394
+ });
395
+ }
396
+ export async function describePythonPlugin(entry, options) {
397
+ const { descriptor } = await runPythonWorker(entry, { mode: 'describe', options });
398
+ return descriptor;
399
+ }
400
+ export async function runPythonPluginCommand(entry, command, input, app) {
401
+ const { result } = await runPythonWorker(entry, { mode: 'run', command, input, options: app.options }, app);
402
+ return result;
403
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.15.3",
3
+ "version": "2.16.1",
4
4
  "description": "Unified Mearl SDK & CLI for local and remote browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,8 +59,8 @@
59
59
  "commander": "^15.0.0",
60
60
  "fastest-levenshtein": "^1.0.16",
61
61
  "ws": "^8.18.0",
62
- "@mearl/cloud-types": "2.15.3",
63
- "@mearl/provider": "2.15.3"
62
+ "@mearl/cloud-types": "2.16.1",
63
+ "@mearl/provider": "2.16.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",