@mearl/client 2.20.1 → 2.20.2

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
@@ -268,6 +268,14 @@ export default function register(app) {
268
268
  }
269
269
  ```
270
270
 
271
+ `app.command(signature, handler, description?, examples?)` 注册 CLI、SDK 和 Agent 可调用的业务命令。
272
+ `app.toolboxCommand(signature, handler, description?, examples?)` 注册仅供工具页面调用的命令,参数和 handler 契约相同;
273
+ 它不出现在 CLI 帮助中,CLI(包括 JSON payload)、SDK、MCP 及插件间的 `app.invoke` 都不能调用。
274
+ 工具页面通过 `window.mearl.invoke` 或 `window.mearl.start` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
275
+ 注册的 `toolboxCommand` 若没有列入该白名单,插件加载会失败。分块上传、分块读取和页面临时资源释放适合使用它;
276
+ 对 Agent 有独立业务价值的查询、导入、压缩、比较等完整操作使用 `app.command`。
277
+ Python 入口也支持 `app.toolboxCommand(...)`,包括 `@app.toolboxCommand(..., description="...")` 装饰器。
278
+
271
279
  声明 `agent` 后工具页可以把任务交给对话 Agent;`layout` 默认为 `drawer`,宿主会提供顶部入口。
272
280
  页面也可在 `setAgentPanelVisible` 或 `runAgentTask` 中为单次交互指定 `drawer` / `workspace` / `inline`;
273
281
  Agent 对话会保存在 Codex 会话历史中。带固定
@@ -21,7 +21,7 @@ export function resolveObservedActionTimeoutSec(data) {
21
21
  }
22
22
  export function resolveActionTimeoutSec(action, data, fallback = 15) {
23
23
  if (action === 'send_request') {
24
- const transferTimeoutMs = Array.isArray(data.files) && data.files.length > 0
24
+ const transferTimeoutMs = data.bodyFile !== undefined || (Array.isArray(data.files) && data.files.length > 0)
25
25
  ? SEND_REQUEST_FILE_TRANSFER_TIMEOUT_MS
26
26
  : 0;
27
27
  return Math.max(fallback, Math.ceil(((data.timeout ?? 30000) + transferTimeoutMs + 5000) / 1000));
@@ -305,7 +305,7 @@ export declare const COMMANDS: readonly [{
305
305
  readonly examples: ["get_rules"];
306
306
  }, {
307
307
  readonly name: "send_request";
308
- readonly description: "发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 文件上传";
308
+ readonly description: "发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 和原始二进制上传";
309
309
  readonly params: [{
310
310
  readonly name: "url";
311
311
  readonly type: "string";
@@ -327,6 +327,10 @@ export declare const COMMANDS: readonly [{
327
327
  readonly name: "body";
328
328
  readonly type: "any";
329
329
  readonly description: "请求体(传对象时自动 JSON 序列化)";
330
+ }, {
331
+ readonly name: "bodyFile";
332
+ readonly type: "object";
333
+ readonly description: "原始二进制请求体:filePath 或 base64 二选一,可选 contentType(默认 application/octet-stream);与 body/formData/files 互斥";
330
334
  }, {
331
335
  readonly name: "formData";
332
336
  readonly type: "object";
@@ -349,9 +353,9 @@ export declare const COMMANDS: readonly [{
349
353
  readonly type: "number";
350
354
  readonly description: "超时毫秒数,默认 30000";
351
355
  }];
352
- readonly notes: readonly ["仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。", "body 不能与 formData/files 组合;multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。", "filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。"];
356
+ readonly notes: readonly ["仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。", "body 不能与 formData/files 组合;bodyFile 以原始字节发送,不包 multipart boundary,不能用于 GET/HEAD。", "bodyFile.filePath 属于 native-host,最大 40 MiB;base64 解码后最大 7 MiB。multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。", "filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。"];
353
357
  readonly result: readonly ["返回 status、statusText、headers、body、bodyType、attachedCookieCount;bodyType 为 json/text/base64,二进制 body 需解码后保存。"];
354
- readonly examples: ["send_request --payload '{\"url\":\"https://api.example.com/data\",\"method\":\"GET\"}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"formData\":{\"folder\":\"assets\"},\"files\":[{\"fieldName\":\"file\",\"filePath\":\"/absolute/path/image.png\"}]}'"];
358
+ readonly examples: ["send_request --payload '{\"url\":\"https://api.example.com/data\",\"method\":\"GET\"}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"formData\":{\"folder\":\"assets\"},\"files\":[{\"fieldName\":\"file\",\"filePath\":\"/absolute/path/image.png\"}]}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"bodyFile\":{\"filePath\":\"/absolute/path/image.png\",\"contentType\":\"image/png\"}}'"];
355
359
  }, {
356
360
  readonly name: "send_mtop_request";
357
361
  readonly description: "使用目标浏览器 Cookie 发起 mtop 请求,自动处理签名和 token";
package/dist/commands.js CHANGED
@@ -306,13 +306,18 @@ export const COMMANDS = [
306
306
  // ── 网络代理 ──────────────────────────────────────────────────────────────
307
307
  {
308
308
  name: 'send_request',
309
- description: '发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 文件上传',
309
+ description: '发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 和原始二进制上传',
310
310
  params: [
311
311
  { name: 'url', type: 'string', required: true, description: '完整目标 URL' },
312
312
  { name: 'method', type: 'string', description: 'HTTP 方法,默认 GET' },
313
313
  { name: 'headers', type: 'object', description: '自定义请求头' },
314
314
  { name: 'params', type: 'object', description: 'URL 查询参数' },
315
315
  { name: 'body', type: 'any', description: '请求体(传对象时自动 JSON 序列化)' },
316
+ {
317
+ name: 'bodyFile',
318
+ type: 'object',
319
+ description: '原始二进制请求体:filePath 或 base64 二选一,可选 contentType(默认 application/octet-stream);与 body/formData/files 互斥',
320
+ },
316
321
  {
317
322
  name: 'formData',
318
323
  type: 'object',
@@ -338,7 +343,8 @@ export const COMMANDS = [
338
343
  ],
339
344
  notes: [
340
345
  '仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。',
341
- 'body 不能与 formData/files 组合;multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。',
346
+ 'body 不能与 formData/files 组合;bodyFile 以原始字节发送,不包 multipart boundary,不能用于 GET/HEAD。',
347
+ 'bodyFile.filePath 属于 native-host,最大 40 MiB;base64 解码后最大 7 MiB。multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。',
342
348
  'filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。',
343
349
  ],
344
350
  result: [
@@ -347,6 +353,7 @@ export const COMMANDS = [
347
353
  examples: [
348
354
  `send_request --payload '{"url":"https://api.example.com/data","method":"GET"}'`,
349
355
  `send_request --payload '{"url":"https://api.example.com/upload","method":"POST","formData":{"folder":"assets"},"files":[{"fieldName":"file","filePath":"/absolute/path/image.png"}]}'`,
356
+ `send_request --payload '{"url":"https://api.example.com/upload","method":"POST","bodyFile":{"filePath":"/absolute/path/image.png","contentType":"image/png"}}'`,
350
357
  ],
351
358
  },
352
359
  {
@@ -83,12 +83,23 @@ export type SendRequestFile = SendRequestFileMetadata & ({
83
83
  /** Multipart filename for the Base64 payload. */
84
84
  filename: string;
85
85
  });
86
+ export type SendRequestBinaryBody = {
87
+ contentType?: string;
88
+ } & ({
89
+ filePath: string;
90
+ base64?: never;
91
+ } | {
92
+ filePath?: never;
93
+ base64: string;
94
+ });
86
95
  export interface SendRequestParams {
87
96
  url: string;
88
97
  method?: string;
89
98
  headers?: Record<string, string>;
90
99
  params?: Record<string, string>;
91
100
  body?: unknown;
101
+ /** Raw bytes, mutually exclusive with body, formData and files. */
102
+ bodyFile?: SendRequestBinaryBody;
92
103
  formData?: Record<string, SendRequestFormValue>;
93
104
  files?: SendRequestFile[];
94
105
  withCookies?: boolean;
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invok
8
8
  export type { UnifiedClientOptions, UnifiedInvokeOptions, UnifiedInvokeOptions as InvokeOptions, } from './unified.js';
9
9
  export type { PluginInvocation, PluginInvokeOptions, PluginProgress, StructuredPluginProgress, } from './plugin.js';
10
10
  export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_CAPABILITY_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
11
- export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
11
+ export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestBinaryBody, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
12
12
  import { type UnifiedInvokeOptions } from './unified.js';
13
13
  import type * as Protocol from './generated-browser-action-protocol.js';
14
14
  export declare function tabCheckpoint(params: Protocol.CaptureCheckpointParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'tab_checkpoint'>>;
package/dist/plugin.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface PluginApp {
24
24
  readonly options: Readonly<PluginOptions>;
25
25
  /** Examples omit the `mearl <plugin>` prefix, which help adds when displayed. */
26
26
  command(signature: string, handler: PluginHandler, description?: string, examples?: string[]): void;
27
+ /** Registers a page-only command, also declared in toolbox.page.commands. */
28
+ toolboxCommand(signature: string, handler: PluginHandler, description?: string, examples?: string[]): void;
27
29
  invoke(action: string, payload?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
28
30
  progress(progress: PluginProgress): void;
29
31
  }
@@ -39,6 +41,7 @@ export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invo
39
41
  commandHelp(namespace: string): string;
40
42
  help(namespace: string, name?: string): string;
41
43
  run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
44
+ runToolbox(invocation: PluginInvocation): Promise<unknown>;
42
45
  }>;
43
46
  export declare function loadRegisteredPlugin(name: string, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<{
44
47
  name: string | undefined;
@@ -48,5 +51,8 @@ export declare function loadRegisteredPlugin(name: string, context: PluginExecut
48
51
  commandHelp(namespace: string): string;
49
52
  help(namespace: string, name?: string): string;
50
53
  run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
54
+ runToolbox(invocation: PluginInvocation): Promise<unknown>;
51
55
  }>;
52
56
  export declare function invokeRegisteredPlugin(name: string, invocation: PluginInvocation, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<unknown>;
57
+ /** Entry point for the native toolbox service; nested plugin calls retain public-command access. */
58
+ export declare function invokeRegisteredToolboxPlugin(name: string, invocation: PluginInvocation, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<unknown>;
package/dist/plugin.js CHANGED
@@ -133,31 +133,33 @@ export async function loadPlugin(entry, context, pluginName) {
133
133
  let name;
134
134
  let description;
135
135
  let toolbox;
136
+ const registerCommand = (signature, handler, description = '', examples = [], toolboxOnly = false) => {
137
+ if (!registering)
138
+ throw new Error('命令只能在入口注册阶段声明');
139
+ const [name, ...args] = signature.trim().split(/\s+/);
140
+ if (!/^[a-z][a-z0-9-]*$/.test(name) ||
141
+ commands.has(name) ||
142
+ typeof handler !== 'function' ||
143
+ !Array.isArray(examples) ||
144
+ examples.some((example) => typeof example !== 'string' || !example.trim())) {
145
+ throw new Error(`无效或重复的命令: ${signature}`);
146
+ }
147
+ const command = new Command(name).description(description);
148
+ for (const argument of args) {
149
+ if (!/^(?:<[a-zA-Z][\w-]*(?:\.\.\.)?>|\[[a-zA-Z][\w-]*(?:\.\.\.)?\])$/.test(argument)) {
150
+ throw new Error(`无效的位置参数: ${argument}`);
151
+ }
152
+ command.argument(argument);
153
+ }
154
+ const names = command.registeredArguments.map(argument => argument.name());
155
+ if (new Set(names).size !== names.length)
156
+ throw new Error(`参数名称重复: ${signature}`);
157
+ commands.set(name, { command, signature, handler, examples: [...examples], toolboxOnly });
158
+ };
136
159
  const app = Object.freeze({
137
160
  options: Object.freeze({ ...context.options }),
138
- command(signature, handler, description = '', examples = []) {
139
- if (!registering)
140
- throw new Error('命令只能在入口注册阶段声明');
141
- const [name, ...args] = signature.trim().split(/\s+/);
142
- if (!/^[a-z][a-z0-9-]*$/.test(name) ||
143
- commands.has(name) ||
144
- typeof handler !== 'function' ||
145
- !Array.isArray(examples) ||
146
- examples.some((example) => typeof example !== 'string' || !example.trim())) {
147
- throw new Error(`无效或重复的命令: ${signature}`);
148
- }
149
- const command = new Command(name).description(description);
150
- for (const argument of args) {
151
- if (!/^(?:<[a-zA-Z][\w-]*(?:\.\.\.)?>|\[[a-zA-Z][\w-]*(?:\.\.\.)?\])$/.test(argument)) {
152
- throw new Error(`无效的位置参数: ${argument}`);
153
- }
154
- command.argument(argument);
155
- }
156
- const names = command.registeredArguments.map(argument => argument.name());
157
- if (new Set(names).size !== names.length)
158
- throw new Error(`参数名称重复: ${signature}`);
159
- commands.set(name, { command, signature, handler, examples: [...examples] });
160
- },
161
+ command: (signature, handler, description = '', examples = []) => registerCommand(signature, handler, description, examples),
162
+ toolboxCommand: (signature, handler, description = '', examples = []) => registerCommand(signature, handler, description, examples, true),
161
163
  invoke(action, payload = {}, options = {}) {
162
164
  if (!executing)
163
165
  throw new Error('app.invoke 只能在命令执行期间调用');
@@ -177,7 +179,8 @@ export async function loadPlugin(entry, context, pluginName) {
177
179
  toolbox = normalizePluginToolboxContribution(descriptor.toolbox);
178
180
  for (const command of descriptor.commands) {
179
181
  const commandName = command.signature.trim().split(/\s+/)[0];
180
- app.command(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
182
+ const register = command.toolboxOnly ? app.toolboxCommand : app.command;
183
+ register(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
181
184
  }
182
185
  }
183
186
  else {
@@ -198,6 +201,11 @@ export async function loadPlugin(entry, context, pluginName) {
198
201
  if (commands.size === 0)
199
202
  throw new Error('入口未注册任何命令');
200
203
  const toolboxCommands = toolbox?.page.commands ?? [];
204
+ for (const [name, command] of commands) {
205
+ if (command.toolboxOnly && !toolboxCommands.includes(name)) {
206
+ throw new Error(`toolboxCommand 未声明在 toolbox.page.commands 中: ${name}`);
207
+ }
208
+ }
201
209
  for (const command of toolboxCommands) {
202
210
  if (!commands.has(command))
203
211
  throw new Error(`toolbox 命令未注册: ${command}`);
@@ -209,24 +217,86 @@ export async function loadPlugin(entry, context, pluginName) {
209
217
  finally {
210
218
  registering = false;
211
219
  }
212
- const find = (name) => {
220
+ const find = (name, toolboxPage = false) => {
213
221
  const command = commands.get(name);
214
222
  if (!command)
215
223
  throw new PluginError('PLUGIN_COMMAND_NOT_FOUND', `未知子命令: ${name}`);
224
+ if (toolboxPage) {
225
+ if (!toolbox?.page.commands.includes(name)) {
226
+ throw new PluginError('PLUGIN_COMMAND_NOT_ALLOWED', `工具页面不允许调用命令: ${name}`);
227
+ }
228
+ }
229
+ else if (command.toolboxOnly) {
230
+ throw new PluginError('PLUGIN_COMMAND_TOOLBOX_ONLY', `命令仅供工具页面调用: ${name}`);
231
+ }
216
232
  return command;
217
233
  };
234
+ const publicCommands = () => [...commands.values()].filter(command => !command.toolboxOnly);
235
+ const run = async (positionals, payload, namedInput = {}, toolboxPage = false) => {
236
+ if (payload !== undefined && positionals.length > 0) {
237
+ throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
238
+ }
239
+ if (payload !== undefined && Object.keys(namedInput).length > 0) {
240
+ throw new PluginError('PLUGIN_INVALID_INPUT', '插件业务选项与 --payload 调用不能混用');
241
+ }
242
+ if (payload !== undefined &&
243
+ (!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
244
+ throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
245
+ }
246
+ const envelope = payload;
247
+ const { command, handler } = find(envelope?.command ?? positionals[0] ?? '', toolboxPage);
248
+ const positionalInput = envelope
249
+ ? { ...envelope.input }
250
+ : Object.fromEntries(command.registeredArguments
251
+ .map((argument, index) => [
252
+ argument.name(),
253
+ argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
254
+ ])
255
+ .filter(([, value]) => value !== undefined));
256
+ for (const name of Object.keys(namedInput)) {
257
+ if (Object.hasOwn(positionalInput, name)) {
258
+ throw new PluginError('PLUGIN_INVALID_INPUT', `参数不能重复传入: ${name}`);
259
+ }
260
+ }
261
+ const input = { ...positionalInput, ...namedInput };
262
+ if (!envelope &&
263
+ !command.registeredArguments.some(argument => argument.variadic) &&
264
+ positionals.length - 1 > command.registeredArguments.length) {
265
+ throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数过多');
266
+ }
267
+ for (const argument of command.registeredArguments) {
268
+ const value = Object.hasOwn(input, argument.name()) ? input[argument.name()] : undefined;
269
+ if (argument.required &&
270
+ (value === undefined ||
271
+ value === null ||
272
+ value === '' ||
273
+ (Array.isArray(value) && value.length === 0))) {
274
+ throw new PluginError('PLUGIN_INVALID_INPUT', `缺少必填参数: ${argument.name()}`);
275
+ }
276
+ if (argument.variadic && value !== undefined && !Array.isArray(value)) {
277
+ throw new PluginError('PLUGIN_INVALID_INPUT', `${argument.name()} 必须为数组`);
278
+ }
279
+ }
280
+ executing = true;
281
+ try {
282
+ return await handler(input);
283
+ }
284
+ finally {
285
+ executing = false;
286
+ }
287
+ };
218
288
  return {
219
289
  name,
220
290
  description,
221
291
  toolbox,
222
292
  version: readPluginVersion(entry),
223
293
  commandHelp(namespace) {
224
- return [...commands.values()]
294
+ return publicCommands()
225
295
  .map(({ command, signature }) => ` ${namespace} ${signature} ${command.description()}`)
226
296
  .join('\n');
227
297
  },
228
298
  help(namespace, name) {
229
- const selected = name ? [find(name)] : [...commands.values()];
299
+ const selected = name ? [find(name)] : publicCommands();
230
300
  const examples = selected.flatMap(({ examples }) => examples);
231
301
  return [
232
302
  '用法:',
@@ -236,58 +306,11 @@ export async function loadPlugin(entry, context, pluginName) {
236
306
  : []),
237
307
  ].join('\n');
238
308
  },
239
- async run(positionals, payload, namedInput = {}) {
240
- if (payload !== undefined && positionals.length > 0) {
241
- throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
242
- }
243
- if (payload !== undefined && Object.keys(namedInput).length > 0) {
244
- throw new PluginError('PLUGIN_INVALID_INPUT', '插件业务选项与 --payload 调用不能混用');
245
- }
246
- if (payload !== undefined &&
247
- (!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
248
- throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
249
- }
250
- const envelope = payload;
251
- const { command, handler } = find(envelope?.command ?? positionals[0] ?? '');
252
- const positionalInput = envelope
253
- ? { ...envelope.input }
254
- : Object.fromEntries(command.registeredArguments
255
- .map((argument, index) => [
256
- argument.name(),
257
- argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
258
- ])
259
- .filter(([, value]) => value !== undefined));
260
- for (const name of Object.keys(namedInput)) {
261
- if (Object.hasOwn(positionalInput, name)) {
262
- throw new PluginError('PLUGIN_INVALID_INPUT', `参数不能重复传入: ${name}`);
263
- }
264
- }
265
- const input = { ...positionalInput, ...namedInput };
266
- if (!envelope &&
267
- !command.registeredArguments.some(argument => argument.variadic) &&
268
- positionals.length - 1 > command.registeredArguments.length) {
269
- throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数过多');
270
- }
271
- for (const argument of command.registeredArguments) {
272
- const value = Object.hasOwn(input, argument.name()) ? input[argument.name()] : undefined;
273
- if (argument.required &&
274
- (value === undefined ||
275
- value === null ||
276
- value === '' ||
277
- (Array.isArray(value) && value.length === 0))) {
278
- throw new PluginError('PLUGIN_INVALID_INPUT', `缺少必填参数: ${argument.name()}`);
279
- }
280
- if (argument.variadic && value !== undefined && !Array.isArray(value)) {
281
- throw new PluginError('PLUGIN_INVALID_INPUT', `${argument.name()} 必须为数组`);
282
- }
283
- }
284
- executing = true;
285
- try {
286
- return await handler(input);
287
- }
288
- finally {
289
- executing = false;
290
- }
309
+ run(positionals, payload, namedInput = {}) {
310
+ return run(positionals, payload, namedInput);
311
+ },
312
+ runToolbox(invocation) {
313
+ return run([], invocation, {}, true);
291
314
  },
292
315
  };
293
316
  }
@@ -348,3 +371,8 @@ async function invokeRegisteredPluginWithState(name, invocation, context, option
348
371
  export function invokeRegisteredPlugin(name, invocation, context, options = {}) {
349
372
  return invokeRegisteredPluginWithState(name, invocation, context, options);
350
373
  }
374
+ /** Entry point for the native toolbox service; nested plugin calls retain public-command access. */
375
+ export async function invokeRegisteredToolboxPlugin(name, invocation, context, options = {}) {
376
+ const plugin = await loadRegisteredPluginWithState(name, context, options);
377
+ return plugin.runToolbox(invocation);
378
+ }
@@ -6,4 +6,5 @@ export interface BuiltPluginPageBundle {
6
6
  chunks: number;
7
7
  sources: Record<string, string>;
8
8
  }
9
+ export declare const PLUGIN_PAGE_RUNTIME_HASH: string;
9
10
  export declare function buildPluginPageBundle(entry: string, pageEntry: string): BuiltPluginPageBundle;
@@ -103,6 +103,8 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
103
103
  };
104
104
 
105
105
  const runningOperations = async () => requestHost('invoke-operations');
106
+ const readClipboard = async () => requestHost('clipboard-read');
107
+ const getActiveTab = async () => requestHost('active-tab');
106
108
 
107
109
  const selectDirectory = async () => {
108
110
  const path = await requestHost('select-directory');
@@ -119,6 +121,14 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
119
121
  await requestHost('clipboard-write', { text });
120
122
  };
121
123
 
124
+ const copyImage = async data => {
125
+ if (typeof data !== 'string' || data.length > 16 * 1024 * 1024 ||
126
+ !/^data:image\/(png|jpeg|webp);base64,/.test(data)) {
127
+ throw new Error('Clipboard image must be a PNG, JPEG or WebP data URL within 16 MiB');
128
+ }
129
+ await requestHost('clipboard-image-write', { data });
130
+ };
131
+
122
132
  const openExternalLink = async url => {
123
133
  if (typeof url !== 'string' || !url || url.length > 8192) {
124
134
  throw new Error('External link must be a non-empty URL no longer than 8192 characters');
@@ -441,7 +451,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
441
451
  return query;
442
452
  };
443
453
 
444
- const api = { copyText, invoke, operation, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
454
+ const api = { copyImage, copyText, getActiveTab, invoke, operation, readClipboard, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
445
455
  const ui = globalThis.__mearlToolboxUi;
446
456
  if (ui !== undefined) {
447
457
  if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
@@ -604,6 +614,11 @@ body {
604
614
  }
605
615
  button, input, select, textarea { font: inherit; }
606
616
  </style>`;
617
+ export const PLUGIN_PAGE_RUNTIME_HASH = createHash('sha256')
618
+ .update(PAGE_BRIDGE_SCRIPT)
619
+ .update(PAGE_SECURITY_META)
620
+ .update(PAGE_THEME_STYLE)
621
+ .digest('hex');
607
622
  function pluginRoot(entry) {
608
623
  const entryDirectory = dirname(entry);
609
624
  return basename(entryDirectory) === 'scripts' ? dirname(entryDirectory) : entryDirectory;
@@ -18,6 +18,7 @@ export interface PluginToolboxAgentConfig {
18
18
  }
19
19
  export interface PluginToolboxContribution {
20
20
  version: 1;
21
+ displayMode?: 'popup' | 'tab' | 'both';
21
22
  title: string;
22
23
  description?: string;
23
24
  icon?: string;
@@ -40,6 +41,7 @@ export interface PluginRegistration {
40
41
  source?: {
41
42
  entry: string;
42
43
  page?: Record<string, string>;
44
+ pageRuntime?: string;
43
45
  };
44
46
  }
45
47
  export declare function pluginRegistrationNeedsRefresh(registration: PluginRegistration, includePage?: boolean): boolean;
@@ -3,7 +3,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, re
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { COMMAND_MAP } from './commands.js';
6
- import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
6
+ import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, PLUGIN_PAGE_RUNTIME_HASH, } from './pluginPageBundle.js';
7
7
  import { pluginFileVersion } from './pluginSource.js';
8
8
  const RESERVED_NAMES = new Set([
9
9
  'check',
@@ -27,6 +27,8 @@ export function pluginRegistrationNeedsRefresh(registration, includePage = false
27
27
  return true;
28
28
  if (!includePage || !registration.toolbox)
29
29
  return false;
30
+ if (registration.source.pageRuntime !== PLUGIN_PAGE_RUNTIME_HASH)
31
+ return true;
30
32
  if (!registration.source.page)
31
33
  return true;
32
34
  return Object.entries(registration.source.page).some(([path, version]) => pluginFileVersion(path) !== version);
@@ -108,6 +110,10 @@ export function normalizePluginToolboxContribution(value) {
108
110
  throw new Error('toolbox 必须是对象');
109
111
  }
110
112
  const candidate = value;
113
+ if (candidate.displayMode !== undefined &&
114
+ !['popup', 'tab', 'both'].includes(candidate.displayMode)) {
115
+ throw new Error('toolbox.displayMode 必须是 popup、tab 或 both');
116
+ }
111
117
  const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
112
118
  const description = typeof candidate.description === 'string' ? candidate.description.trim() : undefined;
113
119
  const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : undefined;
@@ -216,6 +222,7 @@ export function normalizePluginToolboxContribution(value) {
216
222
  return {
217
223
  version: 1,
218
224
  title,
225
+ ...(candidate.displayMode !== undefined ? { displayMode: candidate.displayMode } : {}),
219
226
  ...(description ? { description } : {}),
220
227
  ...(icon ? { icon } : {}),
221
228
  ...(locales ? { locales } : {}),
@@ -281,7 +288,10 @@ export function registerPlugin(name, target, description, options = {}) {
281
288
  }
282
289
  const record = {
283
290
  entry,
284
- source: { entry: entryVersion, ...(builtPage ? { page: builtPage.sources } : {}) },
291
+ source: {
292
+ entry: entryVersion,
293
+ ...(builtPage ? { page: builtPage.sources, pageRuntime: PLUGIN_PAGE_RUNTIME_HASH } : {}),
294
+ },
285
295
  ...(summary ? { description: summary } : {}),
286
296
  ...(managedBy ? { managedBy } : {}),
287
297
  ...(toolbox ? { toolbox } : {}),
@@ -344,6 +354,8 @@ export function findPluginRegistration(name) {
344
354
  typeof source !== 'object' ||
345
355
  Array.isArray(source) ||
346
356
  typeof source.entry !== 'string' ||
357
+ (source.pageRuntime !== undefined &&
358
+ (typeof source.pageRuntime !== 'string' || !/^[a-f0-9]{64}$/.test(source.pageRuntime))) ||
347
359
  (source.page !== undefined &&
348
360
  (!source.page ||
349
361
  typeof source.page !== 'object' ||
@@ -2,6 +2,7 @@ import type { PluginOptions } from './plugin.js';
2
2
  export interface PythonPluginCommand {
3
3
  description: string;
4
4
  signature: string;
5
+ toolboxOnly: boolean;
5
6
  }
6
7
  export interface PythonPluginDescriptor {
7
8
  commands: PythonPluginCommand[];
@@ -53,6 +53,12 @@ class _App:
53
53
  self.request_id = 0
54
54
 
55
55
  def command(self, signature, handler=None, description=""):
56
+ return self._register_command(signature, handler, description, False)
57
+
58
+ def toolboxCommand(self, signature, handler=None, description=""):
59
+ return self._register_command(signature, handler, description, True)
60
+
61
+ def _register_command(self, signature, handler, description, toolbox_only):
56
62
  if not self.registering:
57
63
  raise RuntimeError("命令只能在入口注册阶段声明")
58
64
  if not isinstance(signature, str):
@@ -67,6 +73,7 @@ class _App:
67
73
  "signature": signature,
68
74
  "description": description,
69
75
  "handler": candidate,
76
+ "toolboxOnly": toolbox_only,
70
77
  })
71
78
  return candidate
72
79
 
@@ -163,7 +170,7 @@ try:
163
170
  descriptor = {
164
171
  "type": "ready",
165
172
  "commands": [
166
- {"signature": item["signature"], "description": item["description"]}
173
+ {"signature": item["signature"], "description": item["description"], "toolboxOnly": item["toolboxOnly"]}
167
174
  for item in app.commands
168
175
  ],
169
176
  }
@@ -223,10 +230,15 @@ function parseDescriptor(message) {
223
230
  const commands = message.commands.map(value => {
224
231
  if (!isObject(value) ||
225
232
  typeof value.signature !== 'string' ||
226
- typeof value.description !== 'string') {
233
+ typeof value.description !== 'string' ||
234
+ typeof value.toolboxOnly !== 'boolean') {
227
235
  throw new Error('Python 插件返回了无效的命令描述');
228
236
  }
229
- return { signature: value.signature, description: value.description };
237
+ return {
238
+ signature: value.signature,
239
+ description: value.description,
240
+ toolboxOnly: value.toolboxOnly,
241
+ };
230
242
  });
231
243
  if (message.name !== undefined && typeof message.name !== 'string') {
232
244
  throw new Error('Python 插件返回了无效的 name');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.20.1",
3
+ "version": "2.20.2",
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.20.1",
63
- "@mearl/provider": "2.20.1"
62
+ "@mearl/provider": "2.20.2",
63
+ "@mearl/cloud-types": "2.20.2"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",