@ynhcj/xiaoyi-channel 0.0.207-beta → 0.0.209-beta

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.
@@ -0,0 +1,561 @@
1
+ // HMOS CLI exec hook — CLI tool execution for HarmonyOS skills
2
+ // Per exec-hmos.md §6: intercepts OpenClaw's built-in exec, validates against
3
+ // CLI definitions from skill's references/clis/available_clis.json, and sends
4
+ // FunctionExecute/ExecuteCLI to the device via WebSocket.
5
+ //
6
+ // ┌─────────────────────────────────────────────────────────────────────┐
7
+ // │ §1 Types │
8
+ // │ §2 Cache (globalThis singleton, mtime-based lazy refresh) │
9
+ // │ §3 Parser (tokenizer, argv validator, inputSchema enforcement) │
10
+ // │ §4 Executor (send ExecuteCLI, wait for ExecuteCLIRsp) │
11
+ // │ §5 Hook (before_tool_call registration) │
12
+ // └─────────────────────────────────────────────────────────────────────┘
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import os from "os";
16
+ import { logger } from "../utils/logger.js";
17
+ import { InvokeError, invokeErrorToResult } from "./invoke.js";
18
+ import { getXYWebSocketManager } from "../client.js";
19
+ import { sendCommand } from "../formatter.js";
20
+ import { getCurrentTaskId } from "../task-manager.js";
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // §2 Cache
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+ const DEFAULT_SKILL_ROOT = path.join(os.homedir(), ".openclaw", "workspace", "skills");
25
+ const REFRESH_INTERVAL_MS = 30_000;
26
+ const _g = globalThis;
27
+ const CACHE_SLOT = "__xyCLICache";
28
+ // ── Frontmatter helpers ──────────────────────────
29
+ function parseSkillName(skillDir) {
30
+ const skillMdPath = path.join(skillDir, "SKILL.md");
31
+ let content;
32
+ try {
33
+ content = fs.readFileSync(skillMdPath, "utf-8");
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ return extractNameFromFrontmatter(content);
39
+ }
40
+ function extractNameFromFrontmatter(content) {
41
+ if (content.charCodeAt(0) === 0xfeff)
42
+ content = content.slice(1);
43
+ const lines = content.split(/\r?\n/);
44
+ if (lines.length === 0 || lines[0].trim() !== "---")
45
+ return null;
46
+ for (let i = 1; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (line.trim() === "---")
49
+ break;
50
+ const match = line.match(/^name:\s*(.+)$/);
51
+ if (match) {
52
+ let value = match[1].trim();
53
+ if ((value.startsWith('"') && value.endsWith('"')) ||
54
+ (value.startsWith("'") && value.endsWith("'"))) {
55
+ value = value.slice(1, -1);
56
+ }
57
+ return value.length > 0 ? value : null;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ export function extractCLINames(content) {
63
+ if (content.charCodeAt(0) === 0xfeff)
64
+ content = content.slice(1);
65
+ const lines = content.split(/\r?\n/);
66
+ if (lines.length === 0 || lines[0].trim() !== "---")
67
+ return null;
68
+ let inMetadata = false;
69
+ let inClis = false;
70
+ const cliNames = [];
71
+ for (let i = 1; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ if (line.trim() === "---")
74
+ break;
75
+ if (line.trim() === "metadata:") {
76
+ inMetadata = true;
77
+ continue;
78
+ }
79
+ if (!inMetadata)
80
+ continue;
81
+ if (line.trim() === "clis:") {
82
+ inClis = true;
83
+ continue;
84
+ }
85
+ if (!inClis)
86
+ continue;
87
+ const match = line.match(/^\s*-\s+name:\s*(.+)$/);
88
+ if (match) {
89
+ let value = match[1].trim();
90
+ if ((value.startsWith('"') && value.endsWith('"')) ||
91
+ (value.startsWith("'") && value.endsWith("'"))) {
92
+ value = value.slice(1, -1);
93
+ }
94
+ if (value)
95
+ cliNames.push(value);
96
+ }
97
+ if (!line.trim().startsWith("-") && line.trim() !== "" && line[0] !== " " && line[0] !== "\t") {
98
+ inClis = false;
99
+ }
100
+ }
101
+ return cliNames.length > 0 ? cliNames : null;
102
+ }
103
+ // ── Scan & cache ─────────────────────────────────
104
+ function scanCLIDirectories(rootDir) {
105
+ const skillClis = new Map();
106
+ if (!fs.existsSync(rootDir)) {
107
+ logger.log(`[CLI-CACHE] Skills root not found: ${rootDir}`);
108
+ return skillClis;
109
+ }
110
+ let skillDirs;
111
+ try {
112
+ skillDirs = fs.readdirSync(rootDir, { withFileTypes: true });
113
+ }
114
+ catch (err) {
115
+ logger.error(`[CLI-CACHE] Failed to read: ${rootDir}`, err);
116
+ return skillClis;
117
+ }
118
+ for (const dirent of skillDirs) {
119
+ if (!dirent.isDirectory() || dirent.name.startsWith("."))
120
+ continue;
121
+ const skillDir = path.join(rootDir, dirent.name);
122
+ const skillName = parseSkillName(skillDir);
123
+ if (!skillName)
124
+ continue;
125
+ const skillMdPath = path.join(skillDir, "SKILL.md");
126
+ let mdContent;
127
+ try {
128
+ mdContent = fs.readFileSync(skillMdPath, "utf-8");
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ const declaredCLINames = extractCLINames(mdContent);
134
+ if (!declaredCLINames)
135
+ continue;
136
+ const clisJsonPath = path.join(skillDir, "references", "clis", "available_clis.json");
137
+ if (!fs.existsSync(clisJsonPath)) {
138
+ logger.warn(`[CLI-CACHE] Declared metadata.clis but missing ${clisJsonPath}`);
139
+ continue;
140
+ }
141
+ let cliDefs;
142
+ try {
143
+ const raw = JSON.parse(fs.readFileSync(clisJsonPath, "utf-8"));
144
+ if (!Array.isArray(raw)) {
145
+ logger.error(`[CLI-CACHE] ${clisJsonPath} is not an array`);
146
+ continue;
147
+ }
148
+ cliDefs = raw;
149
+ }
150
+ catch (err) {
151
+ logger.error(`[CLI-CACHE] Failed to parse: ${clisJsonPath}`, err);
152
+ continue;
153
+ }
154
+ const defMap = new Map();
155
+ for (const def of cliDefs) {
156
+ if (def.name)
157
+ defMap.set(def.name, def);
158
+ }
159
+ const entries = [];
160
+ for (const declaredName of declaredCLINames) {
161
+ const match = defMap.get(declaredName);
162
+ if (!match) {
163
+ logger.warn(`[CLI-CACHE] CLI '${declaredName}' declared in SKILL.md but missing from available_clis.json`);
164
+ continue;
165
+ }
166
+ let mtimeMs = 0;
167
+ try {
168
+ mtimeMs = fs.statSync(clisJsonPath).mtimeMs;
169
+ }
170
+ catch { /* ok */ }
171
+ entries.push({ cliName: declaredName, definition: match, filePath: clisJsonPath, mtimeMs });
172
+ }
173
+ if (entries.length > 0) {
174
+ skillClis.set(skillName, entries);
175
+ logger.log(`[CLI-CACHE] Skill '${skillName}': ${entries.length} CLI(s) cached`);
176
+ }
177
+ }
178
+ return skillClis;
179
+ }
180
+ function createCacheState(rootDir) {
181
+ const skillClis = scanCLIDirectories(rootDir);
182
+ let rootMtimeMs = 0;
183
+ try {
184
+ rootMtimeMs = fs.statSync(rootDir).mtimeMs;
185
+ }
186
+ catch { /* ok */ }
187
+ return { skillClis, lastScanMs: Date.now(), rootDir, rootMtimeMs };
188
+ }
189
+ function maybeRefresh(state) {
190
+ const now = Date.now();
191
+ if (now - state.lastScanMs < REFRESH_INTERVAL_MS)
192
+ return;
193
+ let currentMtime = 0;
194
+ try {
195
+ currentMtime = fs.statSync(state.rootDir).mtimeMs;
196
+ }
197
+ catch {
198
+ return;
199
+ }
200
+ if (currentMtime !== state.rootMtimeMs) {
201
+ logger.log("[CLI-CACHE] mtime changed, rescanning...");
202
+ state.skillClis = scanCLIDirectories(state.rootDir);
203
+ state.rootMtimeMs = currentMtime;
204
+ }
205
+ state.lastScanMs = now;
206
+ }
207
+ function wrapState(state) {
208
+ return {
209
+ getCLIs(skillName) { maybeRefresh(state); return state.skillClis.get(skillName) ?? null; },
210
+ getCLI(skillName, cliName) {
211
+ maybeRefresh(state);
212
+ const entries = state.skillClis.get(skillName);
213
+ if (!entries)
214
+ return null;
215
+ return entries.find(e => e.cliName === cliName) ?? null;
216
+ },
217
+ hasCLI(skillName, cliName) { return this.getCLI(skillName, cliName) !== null; },
218
+ async refresh() {
219
+ state.skillClis = scanCLIDirectories(state.rootDir);
220
+ state.lastScanMs = Date.now();
221
+ try {
222
+ state.rootMtimeMs = fs.statSync(state.rootDir).mtimeMs;
223
+ }
224
+ catch {
225
+ state.rootMtimeMs = 0;
226
+ }
227
+ logger.log(`[CLI-CACHE] Refreshed: ${state.skillClis.size} skills with CLIs`);
228
+ },
229
+ getRootDir() { return state.rootDir; },
230
+ };
231
+ }
232
+ export function getCLICache(rootDir) {
233
+ const dir = rootDir ?? DEFAULT_SKILL_ROOT;
234
+ const existing = _g[CACHE_SLOT];
235
+ if (existing && existing.rootDir === dir)
236
+ return wrapState(existing);
237
+ const state = createCacheState(dir);
238
+ _g[CACHE_SLOT] = state;
239
+ logger.log(`[CLI-CACHE] Init: ${state.skillClis.size} skills with CLIs from ${dir}`);
240
+ return wrapState(state);
241
+ }
242
+ // ═══════════════════════════════════════════════════════════════════════════
243
+ // §3 Parser
244
+ // ═══════════════════════════════════════════════════════════════════════════
245
+ const DANGEROUS_PATTERNS = [
246
+ /[`]/,
247
+ /\$\(/,
248
+ /;/,
249
+ /\|/,
250
+ /&&/,
251
+ /\|\|/,
252
+ />/,
253
+ /</,
254
+ /\n/,
255
+ ];
256
+ /** Tokenize a command string into argv, respecting single/double quotes. */
257
+ function tokenizeCommand(command) {
258
+ for (const pattern of DANGEROUS_PATTERNS) {
259
+ if (pattern.test(command)) {
260
+ throw new InvokeError("CLI_COMMAND_BLOCKED", `Command contains dangerous shell syntax: ${pattern}`);
261
+ }
262
+ }
263
+ const tokens = [];
264
+ let current = "";
265
+ let inSingle = false;
266
+ let inDouble = false;
267
+ for (let i = 0; i < command.length; i++) {
268
+ const ch = command[i];
269
+ if (ch === "'" && !inDouble) {
270
+ inSingle = !inSingle;
271
+ continue;
272
+ }
273
+ if (ch === '"' && !inSingle) {
274
+ inDouble = !inDouble;
275
+ continue;
276
+ }
277
+ if (/\s/.test(ch) && !inSingle && !inDouble) {
278
+ if (current.length > 0) {
279
+ tokens.push(current);
280
+ current = "";
281
+ }
282
+ continue;
283
+ }
284
+ current += ch;
285
+ }
286
+ if (current.length > 0)
287
+ tokens.push(current);
288
+ if (inSingle || inDouble) {
289
+ throw new InvokeError("CLI_COMMAND_BLOCKED", "Unmatched quote in command");
290
+ }
291
+ return tokens;
292
+ }
293
+ /**
294
+ * Parse and validate a command string against a CLI definition.
295
+ * Returns typed ParsedCLIArgs ready for ExecuteCLI assembly.
296
+ */
297
+ export function parseAndValidate(command, cliDef) {
298
+ const tokens = tokenizeCommand(command);
299
+ if (tokens.length === 0)
300
+ throw new InvokeError("CLI_COMMAND_BLOCKED", "Empty command");
301
+ // Match prefix against CLI name tokens
302
+ const cliNameTokens = cliDef.name.split(/\s+/);
303
+ if (tokens.length < cliNameTokens.length) {
304
+ throw new InvokeError("CLI_COMMAND_BLOCKED", `Command too short to match CLI prefix '${cliDef.name}'`);
305
+ }
306
+ for (let i = 0; i < cliNameTokens.length; i++) {
307
+ if (tokens[i] !== cliNameTokens[i]) {
308
+ throw new InvokeError("CLI_COMMAND_BLOCKED", `Expected '${cliNameTokens[i]}' at position ${i}, got '${tokens[i]}'`);
309
+ }
310
+ }
311
+ const toolName = cliNameTokens[0];
312
+ const subcommand = cliNameTokens.slice(1).join(" ");
313
+ // Parse --flag pairs from remaining tokens
314
+ const flagTokens = tokens.slice(cliNameTokens.length);
315
+ const schema = cliDef.inputSchema;
316
+ const parsedArgs = {};
317
+ const seenFlags = new Set();
318
+ for (let i = 0; i < flagTokens.length;) {
319
+ const token = flagTokens[i];
320
+ if (!token.startsWith("--")) {
321
+ throw new InvokeError("CLI_COMMAND_BLOCKED", `Unexpected token '${token}': only --flag format is allowed`);
322
+ }
323
+ const flagName = token.slice(2);
324
+ if (!schema?.properties || !(flagName in schema.properties)) {
325
+ throw new InvokeError("CLI_COMMAND_BLOCKED", `Unknown flag '--${flagName}' for CLI '${cliDef.name}'`);
326
+ }
327
+ const propDef = schema.properties[flagName];
328
+ // Duplicate flag handling — only allowed for array type
329
+ if (seenFlags.has(flagName) && propDef.type !== "array") {
330
+ throw new InvokeError("INVALID_PARAM", `Duplicate flag '--${flagName}' (only array-type flags allow repetition)`);
331
+ }
332
+ seenFlags.add(flagName);
333
+ // Boolean flag — presence means true, no value token
334
+ if (propDef.type === "boolean") {
335
+ parsedArgs[flagName] = true;
336
+ i++;
337
+ continue;
338
+ }
339
+ // String / number / array — must have a value token
340
+ i++;
341
+ if (i >= flagTokens.length) {
342
+ throw new InvokeError("INVALID_PARAM", `Flag '--${flagName}' requires a value`);
343
+ }
344
+ const rawValue = flagTokens[i];
345
+ if (propDef.type === "number") {
346
+ const numVal = Number(rawValue);
347
+ if (isNaN(numVal))
348
+ throw new InvokeError("INVALID_PARAM", `Flag '--${flagName}' expects a number, got '${rawValue}'`);
349
+ parsedArgs[flagName] = numVal;
350
+ }
351
+ else if (propDef.type === "string") {
352
+ parsedArgs[flagName] = rawValue;
353
+ }
354
+ else if (propDef.type === "array") {
355
+ if (!Array.isArray(parsedArgs[flagName])) {
356
+ parsedArgs[flagName] = [rawValue];
357
+ }
358
+ else {
359
+ parsedArgs[flagName].push(rawValue);
360
+ }
361
+ }
362
+ i++;
363
+ }
364
+ // Validate required params
365
+ if (schema?.required) {
366
+ for (const req of schema.required) {
367
+ if (!(req in parsedArgs)) {
368
+ throw new InvokeError("INVALID_PARAM", `Missing required flag '--${req}' for CLI '${cliDef.name}'`);
369
+ }
370
+ }
371
+ }
372
+ return { toolName, subcommand, args: parsedArgs };
373
+ }
374
+ // ═══════════════════════════════════════════════════════════════════════════
375
+ // §4 Executor
376
+ // ═══════════════════════════════════════════════════════════════════════════
377
+ const EXECUTE_CLI_TIMEOUT_MS = 60_000;
378
+ // CLI serial lock — separate from Device lock per spec §6.9
379
+ const CLI_LOCKS_SLOT = "__xyCLILocks";
380
+ if (!_g[CLI_LOCKS_SLOT])
381
+ _g[CLI_LOCKS_SLOT] = new Map();
382
+ const cliLocks = _g[CLI_LOCKS_SLOT];
383
+ function acquireCLILock(sessionId) {
384
+ return cliLocks.get(sessionId) ?? null;
385
+ }
386
+ function setCLILock(sessionId) {
387
+ let release;
388
+ const promise = new Promise((r) => { release = r; });
389
+ cliLocks.set(sessionId, promise);
390
+ return () => {
391
+ if (cliLocks.get(sessionId) === promise)
392
+ cliLocks.delete(sessionId);
393
+ release();
394
+ };
395
+ }
396
+ /**
397
+ * Execute a CLI command via WebSocket:
398
+ * 1. Acquire CLI serial lock (per session)
399
+ * 2. Send FunctionExecute/ExecuteCLI
400
+ * 3. Wait for FunctionExecute/ExecuteCLIRsp (via cli-response event)
401
+ * 4. Return result or error
402
+ */
403
+ export async function executeCLI(parsed, sessionCtx, toolCallId) {
404
+ const { config, sessionId, taskId, messageId } = sessionCtx;
405
+ const log = (msg, ...args) => logger.withContext(sessionId, taskId).log(msg, ...args);
406
+ log("[CLI-EXEC] Sending ExecuteCLI", { toolCallId, toolName: parsed.toolName, subcommand: parsed.subcommand, argKeys: Object.keys(parsed.args) });
407
+ const prevLock = acquireCLILock(sessionId);
408
+ if (prevLock) {
409
+ log("[CLI-EXEC] waiting for previous CLI lock");
410
+ await prevLock;
411
+ }
412
+ const unlock = setCLILock(sessionId);
413
+ try {
414
+ return await new Promise((resolve, reject) => {
415
+ const wsManager = getXYWebSocketManager(config);
416
+ const timeout = setTimeout(() => {
417
+ wsManager.off("cli-response", handler);
418
+ const msg = `CLI '${parsed.toolName} ${parsed.subcommand}' timed out after 60s`;
419
+ log("[CLI-EXEC] timed out", msg);
420
+ reject(new InvokeError("TIMEOUT", msg));
421
+ }, EXECUTE_CLI_TIMEOUT_MS);
422
+ const handler = (rspPayload) => {
423
+ if (rspPayload.type !== "result") {
424
+ log("[CLI-EXEC] skipping process event", { type: rspPayload.type });
425
+ return;
426
+ }
427
+ clearTimeout(timeout);
428
+ wsManager.off("cli-response", handler);
429
+ if (rspPayload.status === "success") {
430
+ const text = JSON.stringify(rspPayload.data ?? {});
431
+ log("[CLI-EXEC] succeeded", { resultLength: text.length });
432
+ resolve({ content: [{ type: "text", text }], details: rspPayload.data });
433
+ }
434
+ else {
435
+ const errCode = rspPayload.errCode ?? "UNKNOWN";
436
+ const errMsg = rspPayload.errMsg ?? "No error message";
437
+ log("[CLI-EXEC] failed", { errCode, errMsg });
438
+ reject(new InvokeError("UPSTREAM_ERROR", `CLI execution failed: ${errMsg}`, { errCode, errMsg, suggestion: rspPayload.suggestion, data: rspPayload.data }));
439
+ }
440
+ };
441
+ wsManager.on("cli-response", handler);
442
+ const command = {
443
+ header: { namespace: "FunctionExecute", name: "ExecuteCLI" },
444
+ payload: { toolName: parsed.toolName, subcommand: parsed.subcommand, args: parsed.args },
445
+ };
446
+ const currentTaskId = getCurrentTaskId(sessionId) ?? taskId;
447
+ sendCommand({ config, sessionId, taskId: currentTaskId, messageId, command, toolCallId }).catch((error) => {
448
+ clearTimeout(timeout);
449
+ wsManager.off("cli-response", handler);
450
+ log("[CLI-EXEC] sendCommand failed", { error: error instanceof Error ? error.message : String(error) });
451
+ reject(new InvokeError("NETWORK_ERROR", `Failed to send ExecuteCLI: ${error instanceof Error ? error.message : String(error)}`));
452
+ });
453
+ });
454
+ }
455
+ finally {
456
+ unlock();
457
+ }
458
+ }
459
+ export function cliErrorToResult(err) {
460
+ if (err instanceof InvokeError)
461
+ return invokeErrorToResult(err);
462
+ return invokeErrorToResult(new InvokeError("UNKNOWN", "Unexpected CLI execution error"));
463
+ }
464
+ // ═══════════════════════════════════════════════════════════════════════════
465
+ // §5 Hook
466
+ // ═══════════════════════════════════════════════════════════════════════════
467
+ export { parseSkillName as deriveSkillName };
468
+ /**
469
+ * before_tool_call hook for CLI exec interception.
470
+ *
471
+ * Only handles built-in `exec` calls whose command prefix matches a CLI
472
+ * declared in the current skill's metadata.clis + available_clis.json.
473
+ * Non-matching commands return undefined (noop) so native exec handles them.
474
+ */
475
+ export async function cliBeforeToolCallHandler(event, ctx) {
476
+ if (event.toolName !== "exec")
477
+ return undefined;
478
+ const rawCommand = event.params?.command;
479
+ if (typeof rawCommand !== "string" || rawCommand.trim().length === 0)
480
+ return undefined;
481
+ const t0 = performance.now();
482
+ const command = rawCommand.trim();
483
+ // Need a current skill context
484
+ const workspaceDir = ctx.workspaceDir;
485
+ if (!workspaceDir) {
486
+ logger.log(`[CLI-HOOK] no workspaceDir, noop (${(performance.now() - t0).toFixed(1)}ms)`);
487
+ return undefined;
488
+ }
489
+ const skillName = parseSkillName(workspaceDir);
490
+ if (!skillName) {
491
+ logger.log(`[CLI-HOOK] no skill name in ${workspaceDir}, noop (${(performance.now() - t0).toFixed(1)}ms)`);
492
+ return undefined;
493
+ }
494
+ const cache = getCLICache();
495
+ const skillCLIs = cache.getCLIs(skillName);
496
+ if (!skillCLIs || skillCLIs.length === 0) {
497
+ logger.log(`[CLI-HOOK] Skill '${skillName}' has no CLIs, noop (${(performance.now() - t0).toFixed(1)}ms)`);
498
+ return undefined;
499
+ }
500
+ // Match longest CLI name first
501
+ const sorted = [...skillCLIs].sort((a, b) => b.cliName.length - a.cliName.length);
502
+ let matchedCLI = null;
503
+ for (const entry of sorted) {
504
+ if (command === entry.cliName || command.startsWith(entry.cliName + " ")) {
505
+ matchedCLI = entry;
506
+ break;
507
+ }
508
+ }
509
+ if (!matchedCLI) {
510
+ logger.log(`[CLI-HOOK] No CLI match for command prefix, noop (${(performance.now() - t0).toFixed(1)}ms)`);
511
+ return undefined;
512
+ }
513
+ logger.log(`[CLI-HOOK] Matched CLI '${matchedCLI.cliName}' (${(performance.now() - t0).toFixed(1)}ms)`);
514
+ // Parse and validate
515
+ let parsed;
516
+ try {
517
+ parsed = parseAndValidate(command, matchedCLI.definition);
518
+ }
519
+ catch (err) {
520
+ if (err instanceof InvokeError) {
521
+ logger.warn(`[CLI-HOOK] Validation failed: ${err.code} - ${err.message} (${(performance.now() - t0).toFixed(1)}ms)`);
522
+ return { block: true, blockReason: cliErrorToResult(err).content[0]?.text ?? err.message };
523
+ }
524
+ throw err;
525
+ }
526
+ // requirePermissions → requireApproval
527
+ const permissions = matchedCLI.definition.requirePermissions;
528
+ if (permissions && permissions.length > 0) {
529
+ logger.log(`[CLI-HOOK] requireApproval needed, returning (${(performance.now() - t0).toFixed(1)}ms)`);
530
+ return {
531
+ requireApproval: {
532
+ title: `Execute CLI: ${matchedCLI.cliName}`,
533
+ description: `Requires permissions: ${permissions.join(", ")}. Args: ${JSON.stringify(parsed.args)}`,
534
+ },
535
+ };
536
+ }
537
+ // Get session context and execute
538
+ const sessionCtx = (await import("./session-manager.js")).getCurrentSessionContext();
539
+ if (!sessionCtx) {
540
+ logger.warn(`[CLI-HOOK] No session context, blocking (${(performance.now() - t0).toFixed(1)}ms)`);
541
+ return { block: true, blockReason: JSON.stringify({ code: "CONFIG_MISSING", message: "No active session context for CLI execution", retryable: false }) };
542
+ }
543
+ try {
544
+ const result = await executeCLI(parsed, sessionCtx, event.toolCallId);
545
+ logger.log(`[CLI-HOOK] CLI execution completed (${(performance.now() - t0).toFixed(1)}ms)`);
546
+ return { block: true, blockReason: result.content[0]?.text ?? "" };
547
+ }
548
+ catch (err) {
549
+ const errResult = cliErrorToResult(err);
550
+ logger.warn(`[CLI-HOOK] CLI execution failed (${(performance.now() - t0).toFixed(1)}ms)`);
551
+ return { block: true, blockReason: errResult.content[0]?.text ?? "CLI execution failed" };
552
+ }
553
+ }
554
+ /**
555
+ * Register the CLI hook on the OpenClaw plugin API.
556
+ * Call during `registrationMode === "full"` in the plugin entry point.
557
+ */
558
+ export function registerCLIHook(api) {
559
+ api.on("before_tool_call", cliBeforeToolCallHandler);
560
+ logger.log("[CLI-HOOK] Registered before_tool_call hook for CLI exec");
561
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * invoke meta-tool — single-file implementation.
3
+ *
4
+ * The `invoke` tool is the only tool the LLM sees for calling skill-defined
5
+ * tools. It locates the real tool definition via (bundleName, toolName),
6
+ * dispatches to Cloud/MCP (PluginExecutor) or Device (A2A command), and
7
+ * returns the result.
8
+ *
9
+ * Protocol: invoke.md
10
+ *
11
+ * Sections:
12
+ * 1. Types
13
+ * 2. Errors
14
+ * 3. SKILL.md parser
15
+ * 4. Tool cache
16
+ * 5. Template renderer
17
+ * 6. Cloud executor (REST / SSE / Websocket)
18
+ * 7. Device executor
19
+ * 8. Invoke tool factory (public API)
20
+ */
21
+ import type { ChannelAgentTool } from "openclaw/plugin-sdk";
22
+ import type { SessionContext } from "./session-manager.js";
23
+ export type InvokeErrorCode = "INVALID_PARAM" | "INVALID_TOOL_DEFINITION" | "CONFIG_MISSING" | "AUTH_FAIL" | "PERMISSION_DENIED" | "CLI_COMMAND_BLOCKED" | "RATE_LIMIT" | "TIMEOUT" | "TOOL_NOT_FOUND" | "TOOL_CONFLICT" | "UNSUPPORTED_PLUGIN_TYPE" | "UNSUPPORTED_PROTOCOL" | "DEVICE_TOOL_BLOCKED" | "UPSTREAM_ERROR" | "NETWORK_ERROR" | "UNKNOWN";
24
+ interface ExecuteResult {
25
+ content: Array<{
26
+ type: "text";
27
+ text: string;
28
+ }>;
29
+ details: unknown;
30
+ isError?: boolean;
31
+ }
32
+ export declare class InvokeError extends Error {
33
+ readonly name = "InvokeError";
34
+ readonly code: InvokeErrorCode;
35
+ readonly retryable: boolean;
36
+ readonly details?: Record<string, unknown>;
37
+ constructor(code: InvokeErrorCode, message: string, details?: Record<string, unknown>);
38
+ get status(): number;
39
+ }
40
+ export declare function invokeErrorToResult(error: InvokeError): ExecuteResult;
41
+ /**
42
+ * Create the invoke meta-tool for the given session context.
43
+ *
44
+ * This is the ONLY exported function — the single integration point for
45
+ * create-all-tools.ts.
46
+ */
47
+ export declare function createInvokeTool(ctx: SessionContext): ChannelAgentTool;
48
+ export {};