@mearl/client 2.11.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 +35 -0
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +114 -3
- 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/package.json +6 -2
package/README.md
CHANGED
|
@@ -97,6 +97,41 @@ mearl browser_release
|
|
|
97
97
|
|
|
98
98
|
`check` 有独立的选项说明,运行 `mearl check --help` 查看。其中 `--json` 仅用于输出机器可读的检查结果。
|
|
99
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
|
+
|
|
100
135
|
## 支持的操作
|
|
101
136
|
|
|
102
137
|
| 分类 | Actions |
|
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/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/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",
|