rol-websocket-channel 1.4.2 → 1.4.8
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/{MQTT-API /346/226/260/345/242/236/346/226/207/344/273/266/345/212/237/350/203/275.md" → MQTT-API 5-6.md } +89 -1
- package/dist/index.js +617 -617
- package/dist/message-handler.js +515 -503
- package/dist/src/admin/cli.js +43 -43
- package/dist/src/admin/jsonrpc.js +60 -60
- package/dist/src/admin/lib/fs.js +30 -30
- package/dist/src/admin/lib/paths.js +80 -80
- package/dist/src/admin/methods/admin.js +60 -60
- package/dist/src/admin/methods/agents-extended.js +251 -251
- package/dist/src/admin/methods/artifacts.js +736 -642
- package/dist/src/admin/methods/artifacts.test.js +210 -191
- package/dist/src/admin/methods/cron.js +250 -250
- package/dist/src/admin/methods/index.js +104 -102
- package/dist/src/admin/methods/mem9.js +309 -270
- package/dist/src/admin/methods/mem9.test.js +34 -0
- package/dist/src/admin/methods/memory.js +363 -363
- package/dist/src/admin/methods/models-extended.js +190 -190
- package/dist/src/admin/methods/models.js +195 -195
- package/dist/src/admin/methods/pairing.js +268 -268
- package/dist/src/admin/methods/sessions-extended.js +215 -215
- package/dist/src/admin/methods/sessions.js +75 -75
- package/dist/src/admin/methods/skills-extended.js +157 -157
- package/dist/src/admin/methods/skills-toggle.js +183 -183
- package/dist/src/admin/methods/skills.js +528 -528
- package/dist/src/admin/methods/system.js +271 -180
- package/dist/src/admin/methods/usage.js +1170 -1170
- package/dist/src/admin/types.js +1 -1
- package/dist/src/mqtt/connection-manager.js +209 -209
- package/dist/src/mqtt/index.js +5 -5
- package/dist/src/mqtt/mqtt-client.js +110 -110
- package/dist/src/mqtt/mqtt.test.js +418 -418
- package/dist/src/mqtt/types.js +2 -2
- package/dist/src/shared/context.js +24 -24
- package/dist/src/shared/wrapper.js +23 -23
- package/message-handler.ts +15 -1
- package/openclaw.plugin.json +73 -0
- package/package.json +1 -1
- package/src/admin/methods/artifacts.test.ts +35 -0
- package/src/admin/methods/artifacts.ts +140 -2
- package/src/admin/methods/index.ts +3 -1
- package/src/admin/methods/mem9.test.ts +39 -0
- package/src/admin/methods/mem9.ts +48 -1
- package/src/admin/methods/system.ts +129 -1
|
@@ -1,528 +1,528 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { execFile } from 'node:child_process';
|
|
5
|
-
import { promisify } from 'node:util';
|
|
6
|
-
import { ensureDir, pathExists, readJsonFile } from '../lib/fs.js';
|
|
7
|
-
import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
|
|
8
|
-
const execFileAsync = promisify(execFile);
|
|
9
|
-
export const listInstalledSkills = async (_params, context) => {
|
|
10
|
-
const items = await getInstalledSkillsFromCli(context);
|
|
11
|
-
return {
|
|
12
|
-
count: items.length,
|
|
13
|
-
items
|
|
14
|
-
};
|
|
15
|
-
};
|
|
16
|
-
export const installSkillFromNpm = async (params, context) => {
|
|
17
|
-
const objectParams = expectObject(params);
|
|
18
|
-
const packageSpec = expectString(objectParams.package, 'package');
|
|
19
|
-
const scope = normalizeScope(objectParams.scope);
|
|
20
|
-
const installRoot = resolveInstallRoot(context.openclawRoot, scope);
|
|
21
|
-
await ensureDir(installRoot);
|
|
22
|
-
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openclaw-skill-install-'));
|
|
23
|
-
try {
|
|
24
|
-
const tarballName = await npmPack(packageSpec, tempRoot);
|
|
25
|
-
const tarballPath = path.join(tempRoot, tarballName);
|
|
26
|
-
const extractRoot = path.join(tempRoot, 'extract');
|
|
27
|
-
await ensureDir(extractRoot);
|
|
28
|
-
await extractTarball(tarballPath, extractRoot);
|
|
29
|
-
const packageRoot = await resolvePackedPackageRoot(extractRoot);
|
|
30
|
-
await assertSkillPackage(packageRoot);
|
|
31
|
-
const skillInfo = await resolveSkillIdentity(packageRoot, packageSpec);
|
|
32
|
-
const targetDir = path.join(installRoot, skillInfo.dirName);
|
|
33
|
-
await fs.rm(targetDir, { recursive: true, force: true });
|
|
34
|
-
await fs.cp(packageRoot, targetDir, { recursive: true });
|
|
35
|
-
await fs.writeFile(path.join(targetDir, '.openclaw-admin-bridge-install.json'), JSON.stringify({
|
|
36
|
-
source: 'npm',
|
|
37
|
-
package: packageSpec,
|
|
38
|
-
scope,
|
|
39
|
-
installedAt: new Date().toISOString()
|
|
40
|
-
}, null, 2), 'utf8');
|
|
41
|
-
return {
|
|
42
|
-
ok: true,
|
|
43
|
-
scope,
|
|
44
|
-
package: packageSpec,
|
|
45
|
-
slug: skillInfo.slug,
|
|
46
|
-
skillName: skillInfo.displayName,
|
|
47
|
-
installPath: targetDir
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
finally {
|
|
51
|
-
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
export const searchClawHubSkills = async (params, context) => {
|
|
55
|
-
const objectParams = isObject(params) ? params : {};
|
|
56
|
-
const query = typeof objectParams.query === 'string' ? objectParams.query.trim() : '';
|
|
57
|
-
const args = query ? ['skills', 'search', query, '--json'] : ['skills', 'search', '--json'];
|
|
58
|
-
const result = await runOpenClawSkillCommand(args, context.openclawRoot, context.openclawRoot);
|
|
59
|
-
return {
|
|
60
|
-
ok: true,
|
|
61
|
-
query,
|
|
62
|
-
...result
|
|
63
|
-
};
|
|
64
|
-
};
|
|
65
|
-
export const installSkillFromClawHub = async (params, context) => {
|
|
66
|
-
const objectParams = expectObject(params);
|
|
67
|
-
const slug = expectString(objectParams.slug, 'slug');
|
|
68
|
-
const result = await runClawHubSkillCommandWithFallback('install', slug, context.openclawRoot, context.openclawRoot);
|
|
69
|
-
return {
|
|
70
|
-
ok: true,
|
|
71
|
-
slug,
|
|
72
|
-
...result
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
export const updateSkillFromClawHub = async (params, context) => {
|
|
76
|
-
const objectParams = expectObject(params);
|
|
77
|
-
const slug = expectString(objectParams.slug, 'slug');
|
|
78
|
-
const result = await runClawHubSkillCommandWithFallback('update', slug, context.openclawRoot, context.openclawRoot);
|
|
79
|
-
return {
|
|
80
|
-
ok: true,
|
|
81
|
-
slug,
|
|
82
|
-
...result
|
|
83
|
-
};
|
|
84
|
-
};
|
|
85
|
-
export async function getInstalledSkillsFromCli(context) {
|
|
86
|
-
const skillState = await getSkillState(context);
|
|
87
|
-
const cliItems = await queryOpenClawSkills(context);
|
|
88
|
-
const officialSkills = cliItems.map((item) => normalizeCliSkill(item, skillState));
|
|
89
|
-
const customSkills = await listCustomInstalledSkills(context, skillState.enabledCustomSkills);
|
|
90
|
-
return mergeSkillSources(officialSkills, customSkills);
|
|
91
|
-
}
|
|
92
|
-
function expectObject(value) {
|
|
93
|
-
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
94
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Params must be an object');
|
|
95
|
-
}
|
|
96
|
-
return value;
|
|
97
|
-
}
|
|
98
|
-
function expectString(value, fieldName) {
|
|
99
|
-
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
100
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, `Field '${fieldName}' must be a non-empty string`);
|
|
101
|
-
}
|
|
102
|
-
return value.trim();
|
|
103
|
-
}
|
|
104
|
-
function normalizeScope(rawScope) {
|
|
105
|
-
return rawScope === 'workspace' ? 'workspace' : 'global';
|
|
106
|
-
}
|
|
107
|
-
function resolveInstallRoot(openclawRoot, scope) {
|
|
108
|
-
return scope === 'workspace'
|
|
109
|
-
? path.join(openclawRoot, 'workspace', '.openclaw', 'skills')
|
|
110
|
-
: path.join(openclawRoot, 'skills');
|
|
111
|
-
}
|
|
112
|
-
async function npmPack(packageSpec, cwd) {
|
|
113
|
-
const { stdout } = await execFileAsync('npm', ['pack', packageSpec], { cwd });
|
|
114
|
-
const tarballName = stdout
|
|
115
|
-
.split(/\r?\n/)
|
|
116
|
-
.map((line) => line.trim())
|
|
117
|
-
.filter(Boolean)
|
|
118
|
-
.at(-1);
|
|
119
|
-
if (!tarballName || !tarballName.endsWith('.tgz')) {
|
|
120
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'npm pack did not return a tarball name', {
|
|
121
|
-
package: packageSpec,
|
|
122
|
-
stdout
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
return tarballName;
|
|
126
|
-
}
|
|
127
|
-
export function buildClawHubSkillCommandPlan(action, slug) {
|
|
128
|
-
const args = action === 'install' && slug === 'proactive-agent'
|
|
129
|
-
? [action, slug, '--force']
|
|
130
|
-
: [action, slug];
|
|
131
|
-
return [
|
|
132
|
-
{
|
|
133
|
-
command: process.env.CLAWHUB_BIN || 'clawhub',
|
|
134
|
-
args
|
|
135
|
-
}
|
|
136
|
-
];
|
|
137
|
-
}
|
|
138
|
-
async function runOpenClawSkillCommand(args, cwd, openclawRoot) {
|
|
139
|
-
const command = process.env.OPENCLAW_BIN || 'openclaw';
|
|
140
|
-
return await runSkillCommand(command, args, cwd, openclawRoot);
|
|
141
|
-
}
|
|
142
|
-
async function runClawHubSkillCommandWithFallback(action, slug, cwd, openclawRoot) {
|
|
143
|
-
const attempts = [];
|
|
144
|
-
for (const invocation of buildClawHubSkillCommandPlan(action, slug)) {
|
|
145
|
-
try {
|
|
146
|
-
return await runSkillCommand(invocation.command, invocation.args, cwd, openclawRoot);
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
const data = err instanceof JsonRpcException && isRecord(err.data) ? err.data : {};
|
|
150
|
-
attempts.push({
|
|
151
|
-
command: invocation.command,
|
|
152
|
-
args: invocation.args,
|
|
153
|
-
stdout: typeof data.stdout === 'string' ? data.stdout : '',
|
|
154
|
-
stderr: typeof data.stderr === 'string' ? data.stderr : '',
|
|
155
|
-
error: err instanceof Error ? err.message : String(err)
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
const lastAttempt = attempts.at(-1);
|
|
160
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `ClawHub skill ${action} command failed: ${lastAttempt?.error ?? 'unknown error'}`, {
|
|
161
|
-
action,
|
|
162
|
-
slug,
|
|
163
|
-
attempts
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
async function runSkillCommand(command, args, cwd, openclawRoot) {
|
|
167
|
-
const options = buildOpenClawExecOptions(cwd, openclawRoot);
|
|
168
|
-
const openclawBin = process.env.OPENCLAW_BIN || '';
|
|
169
|
-
const clawhubBin = process.env.CLAWHUB_BIN || '';
|
|
170
|
-
const openclawHome = options.env?.OPENCLAW_HOME || '';
|
|
171
|
-
const home = options.env?.HOME || '';
|
|
172
|
-
console.log(`[skills] exec start: command=${command}, args=${JSON.stringify(args)}, cwd=${cwd}, CLAWHUB_BIN=${clawhubBin}, OPENCLAW_BIN=${openclawBin}, OPENCLAW_HOME=${openclawHome}, HOME=${home}`);
|
|
173
|
-
try {
|
|
174
|
-
const { stdout, stderr } = await execFileAsync(command, args, options);
|
|
175
|
-
console.log(`[skills] exec success: command=${command}, args=${JSON.stringify(args)}, stdoutLength=${stdout.length}, stderrLength=${stderr.length}`);
|
|
176
|
-
return {
|
|
177
|
-
stdout,
|
|
178
|
-
stderr,
|
|
179
|
-
parsed: parseJsonOutput(stdout)
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
|
|
184
|
-
const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
|
|
185
|
-
console.error(`[skills] exec failed: command=${command}, args=${JSON.stringify(args)}, cwd=${cwd}, CLAWHUB_BIN=${clawhubBin}, OPENCLAW_BIN=${openclawBin}, OPENCLAW_HOME=${openclawHome}, HOME=${home}, stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)}`);
|
|
186
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `OpenClaw skill command failed: ${err instanceof Error ? err.message : String(err)}`, {
|
|
187
|
-
command,
|
|
188
|
-
args,
|
|
189
|
-
stdout,
|
|
190
|
-
stderr
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
function parseJsonOutput(stdout) {
|
|
195
|
-
const trimmed = stdout.trim();
|
|
196
|
-
if (!trimmed) {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
return JSON.parse(trimmed);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
async function queryOpenClawSkills(context) {
|
|
207
|
-
const command = process.env.OPENCLAW_BIN || 'openclaw';
|
|
208
|
-
const options = buildOpenClawExecOptions(context.openclawRoot, context.openclawRoot);
|
|
209
|
-
try {
|
|
210
|
-
const { stdout } = await execFileAsync(command, ['skills', 'list', '--json'], options);
|
|
211
|
-
const parsed = JSON.parse(stdout);
|
|
212
|
-
if (Array.isArray(parsed)) {
|
|
213
|
-
return parsed;
|
|
214
|
-
}
|
|
215
|
-
if (isRecord(parsed)) {
|
|
216
|
-
const items = parsed.items;
|
|
217
|
-
const skills = parsed.skills;
|
|
218
|
-
if (Array.isArray(items))
|
|
219
|
-
return items;
|
|
220
|
-
if (Array.isArray(skills))
|
|
221
|
-
return skills;
|
|
222
|
-
}
|
|
223
|
-
throw new Error('Unexpected JSON shape returned by OpenClaw CLI');
|
|
224
|
-
}
|
|
225
|
-
catch (err) {
|
|
226
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `Failed to query OpenClaw skills list: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
function buildOpenClawExecOptions(cwd, openclawRoot) {
|
|
230
|
-
const env = { ...process.env };
|
|
231
|
-
const openclawHome = resolveOpenClawHomeForCli(openclawRoot);
|
|
232
|
-
if (openclawHome) {
|
|
233
|
-
env.OPENCLAW_HOME = openclawHome;
|
|
234
|
-
}
|
|
235
|
-
if (openclawRoot && path.basename(path.resolve(openclawRoot)) === '.openclaw') {
|
|
236
|
-
const home = path.dirname(path.resolve(openclawRoot));
|
|
237
|
-
env.HOME = home;
|
|
238
|
-
if (process.platform === 'win32') {
|
|
239
|
-
env.USERPROFILE = home;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (process.platform === 'win32') {
|
|
243
|
-
return {
|
|
244
|
-
cwd,
|
|
245
|
-
shell: true,
|
|
246
|
-
env
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
return { cwd, env };
|
|
250
|
-
}
|
|
251
|
-
function resolveOpenClawHomeForCli(openclawRoot) {
|
|
252
|
-
if (!openclawRoot) {
|
|
253
|
-
return process.env.OPENCLAW_HOME;
|
|
254
|
-
}
|
|
255
|
-
const resolvedRoot = path.resolve(openclawRoot);
|
|
256
|
-
if (path.basename(resolvedRoot) === '.openclaw') {
|
|
257
|
-
return path.dirname(resolvedRoot);
|
|
258
|
-
}
|
|
259
|
-
return process.env.OPENCLAW_HOME;
|
|
260
|
-
}
|
|
261
|
-
function normalizeCliSkill(skill, skillState) {
|
|
262
|
-
if (!isRecord(skill)) {
|
|
263
|
-
return {
|
|
264
|
-
raw: skill
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
const slug = pickString(skill.slug)
|
|
268
|
-
?? pickString(skill.name)
|
|
269
|
-
?? pickString(skill.id)
|
|
270
|
-
?? 'unknown';
|
|
271
|
-
const bundled = pickBoolean(skill.bundled) ?? false;
|
|
272
|
-
const source = bundled ? 'bundled' : 'official';
|
|
273
|
-
const enabledFromEntry = skillState.entriesEnabled.get(slug);
|
|
274
|
-
const enabled = enabledFromEntry ?? (bundled
|
|
275
|
-
? resolveBundledEnabled(slug, skill, skillState.allowBundled)
|
|
276
|
-
: (pickBoolean(skill.enabled)
|
|
277
|
-
?? pickBoolean(skill.active)
|
|
278
|
-
?? invertBoolean(pickBoolean(skill.disabled))
|
|
279
|
-
?? skillState.enabledCustomSkills.has(slug)));
|
|
280
|
-
return {
|
|
281
|
-
...skill,
|
|
282
|
-
slug,
|
|
283
|
-
name: pickString(skill.name) ?? slug,
|
|
284
|
-
installed: true,
|
|
285
|
-
enabled,
|
|
286
|
-
bundled,
|
|
287
|
-
custom: false,
|
|
288
|
-
source,
|
|
289
|
-
actions: {
|
|
290
|
-
canToggle: true,
|
|
291
|
-
canUninstall: !bundled,
|
|
292
|
-
canAttach: true
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
async function listCustomInstalledSkills(context, enabledSkills) {
|
|
297
|
-
const roots = [
|
|
298
|
-
{ scope: 'global', dir: path.join(context.openclawRoot, 'skills') },
|
|
299
|
-
{ scope: 'workspace', dir: path.join(context.openclawRoot, 'workspace', '.openclaw', 'skills') }
|
|
300
|
-
];
|
|
301
|
-
const items = [];
|
|
302
|
-
for (const root of roots) {
|
|
303
|
-
if (!(await pathExists(root.dir))) {
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
const entries = await fs.readdir(root.dir, { withFileTypes: true });
|
|
307
|
-
for (const entry of entries) {
|
|
308
|
-
if (!entry.isDirectory()) {
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
const installPath = path.join(root.dir, entry.name);
|
|
312
|
-
const manifest = await readSkillManifest(installPath);
|
|
313
|
-
const installMeta = await readInstallMeta(installPath);
|
|
314
|
-
const installPackage = pickString(installMeta.package);
|
|
315
|
-
const fallbackBase = installPackage && installPackage.trim().length > 0 ? installPackage.trim() : entry.name;
|
|
316
|
-
const fallbackSegment = extractSkillSegment(fallbackBase);
|
|
317
|
-
const slug = pickString(manifest.slug) ?? sanitizeDirName(fallbackSegment);
|
|
318
|
-
const displayName = pickString(manifest.name) ?? slug;
|
|
319
|
-
const aliases = buildCustomSkillAliases(slug, displayName, entry.name, installPackage);
|
|
320
|
-
items.push({
|
|
321
|
-
slug,
|
|
322
|
-
name: displayName,
|
|
323
|
-
description: pickString(manifest.description) ?? '',
|
|
324
|
-
version: pickString(manifest.version) ?? null,
|
|
325
|
-
source: 'custom-npm',
|
|
326
|
-
bundled: false,
|
|
327
|
-
custom: true,
|
|
328
|
-
installed: true,
|
|
329
|
-
enabled: enabledSkills.has(slug),
|
|
330
|
-
eligible: true,
|
|
331
|
-
scope: root.scope,
|
|
332
|
-
installPath,
|
|
333
|
-
package: pickString(installMeta.package) ?? null,
|
|
334
|
-
aliases,
|
|
335
|
-
actions: {
|
|
336
|
-
canToggle: true,
|
|
337
|
-
canUninstall: true,
|
|
338
|
-
canAttach: true
|
|
339
|
-
}
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return items;
|
|
344
|
-
}
|
|
345
|
-
async function readSkillManifest(skillDir) {
|
|
346
|
-
const skillJsonPath = path.join(skillDir, 'skill.json');
|
|
347
|
-
const packageJsonPath = path.join(skillDir, 'package.json');
|
|
348
|
-
if (await pathExists(skillJsonPath)) {
|
|
349
|
-
return await readJsonFile(skillJsonPath);
|
|
350
|
-
}
|
|
351
|
-
if (await pathExists(packageJsonPath)) {
|
|
352
|
-
return await readJsonFile(packageJsonPath);
|
|
353
|
-
}
|
|
354
|
-
return {};
|
|
355
|
-
}
|
|
356
|
-
async function readInstallMeta(skillDir) {
|
|
357
|
-
const installMetaPath = path.join(skillDir, '.openclaw-admin-bridge-install.json');
|
|
358
|
-
if (!(await pathExists(installMetaPath))) {
|
|
359
|
-
return {};
|
|
360
|
-
}
|
|
361
|
-
return await readJsonFile(installMetaPath);
|
|
362
|
-
}
|
|
363
|
-
function mergeSkillSources(officialSkills, customSkills) {
|
|
364
|
-
const merged = new Map();
|
|
365
|
-
for (const skill of officialSkills) {
|
|
366
|
-
const slug = typeof skill.slug === 'string' ? skill.slug : null;
|
|
367
|
-
if (slug) {
|
|
368
|
-
merged.set(slug, skill);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
for (const custom of customSkills) {
|
|
372
|
-
const existing = merged.get(custom.slug);
|
|
373
|
-
if (existing && isRecord(existing)) {
|
|
374
|
-
merged.set(custom.slug, {
|
|
375
|
-
...existing,
|
|
376
|
-
custom: true,
|
|
377
|
-
customInstallPath: custom.installPath,
|
|
378
|
-
customPackage: custom.package,
|
|
379
|
-
customAliases: custom.aliases,
|
|
380
|
-
actions: {
|
|
381
|
-
canToggle: true,
|
|
382
|
-
canUninstall: true,
|
|
383
|
-
canAttach: true
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
merged.set(custom.slug, custom);
|
|
389
|
-
}
|
|
390
|
-
return Array.from(merged.values()).sort((a, b) => {
|
|
391
|
-
const aSlug = isRecord(a) && typeof a.slug === 'string' ? a.slug : '';
|
|
392
|
-
const bSlug = isRecord(b) && typeof b.slug === 'string' ? b.slug : '';
|
|
393
|
-
return aSlug.localeCompare(bSlug);
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
async function getSkillState(context) {
|
|
397
|
-
const configPath = path.join(context.openclawRoot, 'openclaw.json');
|
|
398
|
-
if (!(await pathExists(configPath))) {
|
|
399
|
-
return {
|
|
400
|
-
enabledCustomSkills: new Set(),
|
|
401
|
-
allowBundled: null,
|
|
402
|
-
entriesEnabled: new Map()
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
const config = await readJsonFile(configPath);
|
|
406
|
-
const skills = config.agents?.defaults?.skills;
|
|
407
|
-
const enabledCustomSkills = new Set(Array.isArray(skills)
|
|
408
|
-
? skills.filter((item) => typeof item === 'string')
|
|
409
|
-
: []);
|
|
410
|
-
const allowBundled = Array.isArray(config.skills?.allowBundled)
|
|
411
|
-
? new Set(config.skills?.allowBundled.filter((item) => typeof item === 'string'))
|
|
412
|
-
: null;
|
|
413
|
-
const entriesEnabled = new Map();
|
|
414
|
-
if (config.skills?.entries && typeof config.skills.entries === 'object') {
|
|
415
|
-
for (const [key, value] of Object.entries(config.skills.entries)) {
|
|
416
|
-
if (value && typeof value === 'object' && typeof value.enabled === 'boolean') {
|
|
417
|
-
entriesEnabled.set(key, value.enabled);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
enabledCustomSkills,
|
|
423
|
-
allowBundled,
|
|
424
|
-
entriesEnabled
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
function isRecord(value) {
|
|
428
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
429
|
-
}
|
|
430
|
-
function isObject(value) {
|
|
431
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
432
|
-
}
|
|
433
|
-
function pickString(value) {
|
|
434
|
-
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
435
|
-
}
|
|
436
|
-
function pickBoolean(value) {
|
|
437
|
-
return typeof value === 'boolean' ? value : undefined;
|
|
438
|
-
}
|
|
439
|
-
function invertBoolean(value) {
|
|
440
|
-
return value === undefined ? undefined : !value;
|
|
441
|
-
}
|
|
442
|
-
function resolveBundledEnabled(slug, skill, allowBundled) {
|
|
443
|
-
if (allowBundled === null) {
|
|
444
|
-
return (pickBoolean(skill.enabled)
|
|
445
|
-
?? pickBoolean(skill.active)
|
|
446
|
-
?? invertBoolean(pickBoolean(skill.disabled))
|
|
447
|
-
?? true);
|
|
448
|
-
}
|
|
449
|
-
return allowBundled.has(slug);
|
|
450
|
-
}
|
|
451
|
-
async function extractTarball(tarballPath, extractRoot) {
|
|
452
|
-
await execFileAsync('tar', ['-xzf', tarballPath, '-C', extractRoot]);
|
|
453
|
-
}
|
|
454
|
-
async function resolvePackedPackageRoot(extractRoot) {
|
|
455
|
-
const packageRoot = path.join(extractRoot, 'package');
|
|
456
|
-
if (await pathExists(packageRoot)) {
|
|
457
|
-
return packageRoot;
|
|
458
|
-
}
|
|
459
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'Packed npm artifact did not contain package/ root', {
|
|
460
|
-
extractRoot
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
async function assertSkillPackage(packageRoot) {
|
|
464
|
-
if (!(await pathExists(path.join(packageRoot, 'SKILL.md')))) {
|
|
465
|
-
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'npm package is not a valid skill package: missing SKILL.md');
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
async function resolveSkillIdentity(packageRoot, packageSpec) {
|
|
469
|
-
const skillJsonPath = path.join(packageRoot, 'skill.json');
|
|
470
|
-
let manifest = null;
|
|
471
|
-
if (await pathExists(skillJsonPath)) {
|
|
472
|
-
manifest = await readJsonFile(skillJsonPath);
|
|
473
|
-
}
|
|
474
|
-
const fallbackBase = packageSpec && packageSpec.trim().length > 0 ? packageSpec.trim() : packageSpec;
|
|
475
|
-
const fallbackSegment = extractSkillSegment(fallbackBase);
|
|
476
|
-
const slug = pickString(manifest?.slug) ?? sanitizeDirName(fallbackSegment);
|
|
477
|
-
const displayName = pickString(manifest?.name) ?? slug;
|
|
478
|
-
const dirName = sanitizeDirName(slug);
|
|
479
|
-
return { slug, displayName, dirName };
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* 从包名中提取用于推断 slug 的片段:
|
|
483
|
-
* - scoped 包(@foo/bar)→ 取 scope 部分 foo(与 OpenClaw CLI 行为一致)
|
|
484
|
-
* - 普通带斜杠包(foo/bar)→ 取最后段 bar
|
|
485
|
-
* - 无斜杠(foo)→ 原样返回
|
|
486
|
-
*/
|
|
487
|
-
function extractSkillSegment(packageName) {
|
|
488
|
-
if (packageName.startsWith('@') && packageName.includes('/')) {
|
|
489
|
-
// @foo/bar → foo
|
|
490
|
-
return packageName.split('/')[0].replace(/^@/, '');
|
|
491
|
-
}
|
|
492
|
-
if (packageName.includes('/')) {
|
|
493
|
-
// foo/bar → bar
|
|
494
|
-
return packageName.split('/').at(-1) ?? packageName;
|
|
495
|
-
}
|
|
496
|
-
return packageName;
|
|
497
|
-
}
|
|
498
|
-
function sanitizeDirName(value) {
|
|
499
|
-
return value
|
|
500
|
-
.replace(/^@/, '')
|
|
501
|
-
.replace(/[\\/]/g, '-')
|
|
502
|
-
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
503
|
-
.replace(/-+/g, '-')
|
|
504
|
-
.replace(/^-|-$/g, '')
|
|
505
|
-
.toLowerCase();
|
|
506
|
-
}
|
|
507
|
-
function buildCustomSkillAliases(slug, displayName, dirName, packageName) {
|
|
508
|
-
const values = new Set();
|
|
509
|
-
const add = (value) => {
|
|
510
|
-
if (!value)
|
|
511
|
-
return;
|
|
512
|
-
const trimmed = value.trim();
|
|
513
|
-
if (!trimmed)
|
|
514
|
-
return;
|
|
515
|
-
values.add(trimmed);
|
|
516
|
-
};
|
|
517
|
-
add(slug);
|
|
518
|
-
add(displayName);
|
|
519
|
-
add(dirName);
|
|
520
|
-
add(sanitizeDirName(dirName));
|
|
521
|
-
add(packageName);
|
|
522
|
-
if (packageName) {
|
|
523
|
-
const lastSegment = packageName.includes('/') ? (packageName.split('/').at(-1) ?? packageName) : packageName;
|
|
524
|
-
add(lastSegment);
|
|
525
|
-
add(sanitizeDirName(lastSegment));
|
|
526
|
-
}
|
|
527
|
-
return Array.from(values);
|
|
528
|
-
}
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { ensureDir, pathExists, readJsonFile } from '../lib/fs.js';
|
|
7
|
+
import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
export const listInstalledSkills = async (_params, context) => {
|
|
10
|
+
const items = await getInstalledSkillsFromCli(context);
|
|
11
|
+
return {
|
|
12
|
+
count: items.length,
|
|
13
|
+
items
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export const installSkillFromNpm = async (params, context) => {
|
|
17
|
+
const objectParams = expectObject(params);
|
|
18
|
+
const packageSpec = expectString(objectParams.package, 'package');
|
|
19
|
+
const scope = normalizeScope(objectParams.scope);
|
|
20
|
+
const installRoot = resolveInstallRoot(context.openclawRoot, scope);
|
|
21
|
+
await ensureDir(installRoot);
|
|
22
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openclaw-skill-install-'));
|
|
23
|
+
try {
|
|
24
|
+
const tarballName = await npmPack(packageSpec, tempRoot);
|
|
25
|
+
const tarballPath = path.join(tempRoot, tarballName);
|
|
26
|
+
const extractRoot = path.join(tempRoot, 'extract');
|
|
27
|
+
await ensureDir(extractRoot);
|
|
28
|
+
await extractTarball(tarballPath, extractRoot);
|
|
29
|
+
const packageRoot = await resolvePackedPackageRoot(extractRoot);
|
|
30
|
+
await assertSkillPackage(packageRoot);
|
|
31
|
+
const skillInfo = await resolveSkillIdentity(packageRoot, packageSpec);
|
|
32
|
+
const targetDir = path.join(installRoot, skillInfo.dirName);
|
|
33
|
+
await fs.rm(targetDir, { recursive: true, force: true });
|
|
34
|
+
await fs.cp(packageRoot, targetDir, { recursive: true });
|
|
35
|
+
await fs.writeFile(path.join(targetDir, '.openclaw-admin-bridge-install.json'), JSON.stringify({
|
|
36
|
+
source: 'npm',
|
|
37
|
+
package: packageSpec,
|
|
38
|
+
scope,
|
|
39
|
+
installedAt: new Date().toISOString()
|
|
40
|
+
}, null, 2), 'utf8');
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
scope,
|
|
44
|
+
package: packageSpec,
|
|
45
|
+
slug: skillInfo.slug,
|
|
46
|
+
skillName: skillInfo.displayName,
|
|
47
|
+
installPath: targetDir
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
export const searchClawHubSkills = async (params, context) => {
|
|
55
|
+
const objectParams = isObject(params) ? params : {};
|
|
56
|
+
const query = typeof objectParams.query === 'string' ? objectParams.query.trim() : '';
|
|
57
|
+
const args = query ? ['skills', 'search', query, '--json'] : ['skills', 'search', '--json'];
|
|
58
|
+
const result = await runOpenClawSkillCommand(args, context.openclawRoot, context.openclawRoot);
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
query,
|
|
62
|
+
...result
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export const installSkillFromClawHub = async (params, context) => {
|
|
66
|
+
const objectParams = expectObject(params);
|
|
67
|
+
const slug = expectString(objectParams.slug, 'slug');
|
|
68
|
+
const result = await runClawHubSkillCommandWithFallback('install', slug, context.openclawRoot, context.openclawRoot);
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
slug,
|
|
72
|
+
...result
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export const updateSkillFromClawHub = async (params, context) => {
|
|
76
|
+
const objectParams = expectObject(params);
|
|
77
|
+
const slug = expectString(objectParams.slug, 'slug');
|
|
78
|
+
const result = await runClawHubSkillCommandWithFallback('update', slug, context.openclawRoot, context.openclawRoot);
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
slug,
|
|
82
|
+
...result
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export async function getInstalledSkillsFromCli(context) {
|
|
86
|
+
const skillState = await getSkillState(context);
|
|
87
|
+
const cliItems = await queryOpenClawSkills(context);
|
|
88
|
+
const officialSkills = cliItems.map((item) => normalizeCliSkill(item, skillState));
|
|
89
|
+
const customSkills = await listCustomInstalledSkills(context, skillState.enabledCustomSkills);
|
|
90
|
+
return mergeSkillSources(officialSkills, customSkills);
|
|
91
|
+
}
|
|
92
|
+
function expectObject(value) {
|
|
93
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
94
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Params must be an object');
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function expectString(value, fieldName) {
|
|
99
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
100
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, `Field '${fieldName}' must be a non-empty string`);
|
|
101
|
+
}
|
|
102
|
+
return value.trim();
|
|
103
|
+
}
|
|
104
|
+
function normalizeScope(rawScope) {
|
|
105
|
+
return rawScope === 'workspace' ? 'workspace' : 'global';
|
|
106
|
+
}
|
|
107
|
+
function resolveInstallRoot(openclawRoot, scope) {
|
|
108
|
+
return scope === 'workspace'
|
|
109
|
+
? path.join(openclawRoot, 'workspace', '.openclaw', 'skills')
|
|
110
|
+
: path.join(openclawRoot, 'skills');
|
|
111
|
+
}
|
|
112
|
+
async function npmPack(packageSpec, cwd) {
|
|
113
|
+
const { stdout } = await execFileAsync('npm', ['pack', packageSpec], { cwd });
|
|
114
|
+
const tarballName = stdout
|
|
115
|
+
.split(/\r?\n/)
|
|
116
|
+
.map((line) => line.trim())
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.at(-1);
|
|
119
|
+
if (!tarballName || !tarballName.endsWith('.tgz')) {
|
|
120
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'npm pack did not return a tarball name', {
|
|
121
|
+
package: packageSpec,
|
|
122
|
+
stdout
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return tarballName;
|
|
126
|
+
}
|
|
127
|
+
export function buildClawHubSkillCommandPlan(action, slug) {
|
|
128
|
+
const args = action === 'install' && slug === 'proactive-agent'
|
|
129
|
+
? [action, slug, '--force']
|
|
130
|
+
: [action, slug];
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
command: process.env.CLAWHUB_BIN || 'clawhub',
|
|
134
|
+
args
|
|
135
|
+
}
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
async function runOpenClawSkillCommand(args, cwd, openclawRoot) {
|
|
139
|
+
const command = process.env.OPENCLAW_BIN || 'openclaw';
|
|
140
|
+
return await runSkillCommand(command, args, cwd, openclawRoot);
|
|
141
|
+
}
|
|
142
|
+
async function runClawHubSkillCommandWithFallback(action, slug, cwd, openclawRoot) {
|
|
143
|
+
const attempts = [];
|
|
144
|
+
for (const invocation of buildClawHubSkillCommandPlan(action, slug)) {
|
|
145
|
+
try {
|
|
146
|
+
return await runSkillCommand(invocation.command, invocation.args, cwd, openclawRoot);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
const data = err instanceof JsonRpcException && isRecord(err.data) ? err.data : {};
|
|
150
|
+
attempts.push({
|
|
151
|
+
command: invocation.command,
|
|
152
|
+
args: invocation.args,
|
|
153
|
+
stdout: typeof data.stdout === 'string' ? data.stdout : '',
|
|
154
|
+
stderr: typeof data.stderr === 'string' ? data.stderr : '',
|
|
155
|
+
error: err instanceof Error ? err.message : String(err)
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const lastAttempt = attempts.at(-1);
|
|
160
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `ClawHub skill ${action} command failed: ${lastAttempt?.error ?? 'unknown error'}`, {
|
|
161
|
+
action,
|
|
162
|
+
slug,
|
|
163
|
+
attempts
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function runSkillCommand(command, args, cwd, openclawRoot) {
|
|
167
|
+
const options = buildOpenClawExecOptions(cwd, openclawRoot);
|
|
168
|
+
const openclawBin = process.env.OPENCLAW_BIN || '';
|
|
169
|
+
const clawhubBin = process.env.CLAWHUB_BIN || '';
|
|
170
|
+
const openclawHome = options.env?.OPENCLAW_HOME || '';
|
|
171
|
+
const home = options.env?.HOME || '';
|
|
172
|
+
console.log(`[skills] exec start: command=${command}, args=${JSON.stringify(args)}, cwd=${cwd}, CLAWHUB_BIN=${clawhubBin}, OPENCLAW_BIN=${openclawBin}, OPENCLAW_HOME=${openclawHome}, HOME=${home}`);
|
|
173
|
+
try {
|
|
174
|
+
const { stdout, stderr } = await execFileAsync(command, args, options);
|
|
175
|
+
console.log(`[skills] exec success: command=${command}, args=${JSON.stringify(args)}, stdoutLength=${stdout.length}, stderrLength=${stderr.length}`);
|
|
176
|
+
return {
|
|
177
|
+
stdout,
|
|
178
|
+
stderr,
|
|
179
|
+
parsed: parseJsonOutput(stdout)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
|
|
184
|
+
const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
|
|
185
|
+
console.error(`[skills] exec failed: command=${command}, args=${JSON.stringify(args)}, cwd=${cwd}, CLAWHUB_BIN=${clawhubBin}, OPENCLAW_BIN=${openclawBin}, OPENCLAW_HOME=${openclawHome}, HOME=${home}, stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)}`);
|
|
186
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `OpenClaw skill command failed: ${err instanceof Error ? err.message : String(err)}`, {
|
|
187
|
+
command,
|
|
188
|
+
args,
|
|
189
|
+
stdout,
|
|
190
|
+
stderr
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function parseJsonOutput(stdout) {
|
|
195
|
+
const trimmed = stdout.trim();
|
|
196
|
+
if (!trimmed) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
return JSON.parse(trimmed);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function queryOpenClawSkills(context) {
|
|
207
|
+
const command = process.env.OPENCLAW_BIN || 'openclaw';
|
|
208
|
+
const options = buildOpenClawExecOptions(context.openclawRoot, context.openclawRoot);
|
|
209
|
+
try {
|
|
210
|
+
const { stdout } = await execFileAsync(command, ['skills', 'list', '--json'], options);
|
|
211
|
+
const parsed = JSON.parse(stdout);
|
|
212
|
+
if (Array.isArray(parsed)) {
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
if (isRecord(parsed)) {
|
|
216
|
+
const items = parsed.items;
|
|
217
|
+
const skills = parsed.skills;
|
|
218
|
+
if (Array.isArray(items))
|
|
219
|
+
return items;
|
|
220
|
+
if (Array.isArray(skills))
|
|
221
|
+
return skills;
|
|
222
|
+
}
|
|
223
|
+
throw new Error('Unexpected JSON shape returned by OpenClaw CLI');
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `Failed to query OpenClaw skills list: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function buildOpenClawExecOptions(cwd, openclawRoot) {
|
|
230
|
+
const env = { ...process.env };
|
|
231
|
+
const openclawHome = resolveOpenClawHomeForCli(openclawRoot);
|
|
232
|
+
if (openclawHome) {
|
|
233
|
+
env.OPENCLAW_HOME = openclawHome;
|
|
234
|
+
}
|
|
235
|
+
if (openclawRoot && path.basename(path.resolve(openclawRoot)) === '.openclaw') {
|
|
236
|
+
const home = path.dirname(path.resolve(openclawRoot));
|
|
237
|
+
env.HOME = home;
|
|
238
|
+
if (process.platform === 'win32') {
|
|
239
|
+
env.USERPROFILE = home;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (process.platform === 'win32') {
|
|
243
|
+
return {
|
|
244
|
+
cwd,
|
|
245
|
+
shell: true,
|
|
246
|
+
env
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { cwd, env };
|
|
250
|
+
}
|
|
251
|
+
function resolveOpenClawHomeForCli(openclawRoot) {
|
|
252
|
+
if (!openclawRoot) {
|
|
253
|
+
return process.env.OPENCLAW_HOME;
|
|
254
|
+
}
|
|
255
|
+
const resolvedRoot = path.resolve(openclawRoot);
|
|
256
|
+
if (path.basename(resolvedRoot) === '.openclaw') {
|
|
257
|
+
return path.dirname(resolvedRoot);
|
|
258
|
+
}
|
|
259
|
+
return process.env.OPENCLAW_HOME;
|
|
260
|
+
}
|
|
261
|
+
function normalizeCliSkill(skill, skillState) {
|
|
262
|
+
if (!isRecord(skill)) {
|
|
263
|
+
return {
|
|
264
|
+
raw: skill
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const slug = pickString(skill.slug)
|
|
268
|
+
?? pickString(skill.name)
|
|
269
|
+
?? pickString(skill.id)
|
|
270
|
+
?? 'unknown';
|
|
271
|
+
const bundled = pickBoolean(skill.bundled) ?? false;
|
|
272
|
+
const source = bundled ? 'bundled' : 'official';
|
|
273
|
+
const enabledFromEntry = skillState.entriesEnabled.get(slug);
|
|
274
|
+
const enabled = enabledFromEntry ?? (bundled
|
|
275
|
+
? resolveBundledEnabled(slug, skill, skillState.allowBundled)
|
|
276
|
+
: (pickBoolean(skill.enabled)
|
|
277
|
+
?? pickBoolean(skill.active)
|
|
278
|
+
?? invertBoolean(pickBoolean(skill.disabled))
|
|
279
|
+
?? skillState.enabledCustomSkills.has(slug)));
|
|
280
|
+
return {
|
|
281
|
+
...skill,
|
|
282
|
+
slug,
|
|
283
|
+
name: pickString(skill.name) ?? slug,
|
|
284
|
+
installed: true,
|
|
285
|
+
enabled,
|
|
286
|
+
bundled,
|
|
287
|
+
custom: false,
|
|
288
|
+
source,
|
|
289
|
+
actions: {
|
|
290
|
+
canToggle: true,
|
|
291
|
+
canUninstall: !bundled,
|
|
292
|
+
canAttach: true
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function listCustomInstalledSkills(context, enabledSkills) {
|
|
297
|
+
const roots = [
|
|
298
|
+
{ scope: 'global', dir: path.join(context.openclawRoot, 'skills') },
|
|
299
|
+
{ scope: 'workspace', dir: path.join(context.openclawRoot, 'workspace', '.openclaw', 'skills') }
|
|
300
|
+
];
|
|
301
|
+
const items = [];
|
|
302
|
+
for (const root of roots) {
|
|
303
|
+
if (!(await pathExists(root.dir))) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const entries = await fs.readdir(root.dir, { withFileTypes: true });
|
|
307
|
+
for (const entry of entries) {
|
|
308
|
+
if (!entry.isDirectory()) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const installPath = path.join(root.dir, entry.name);
|
|
312
|
+
const manifest = await readSkillManifest(installPath);
|
|
313
|
+
const installMeta = await readInstallMeta(installPath);
|
|
314
|
+
const installPackage = pickString(installMeta.package);
|
|
315
|
+
const fallbackBase = installPackage && installPackage.trim().length > 0 ? installPackage.trim() : entry.name;
|
|
316
|
+
const fallbackSegment = extractSkillSegment(fallbackBase);
|
|
317
|
+
const slug = pickString(manifest.slug) ?? sanitizeDirName(fallbackSegment);
|
|
318
|
+
const displayName = pickString(manifest.name) ?? slug;
|
|
319
|
+
const aliases = buildCustomSkillAliases(slug, displayName, entry.name, installPackage);
|
|
320
|
+
items.push({
|
|
321
|
+
slug,
|
|
322
|
+
name: displayName,
|
|
323
|
+
description: pickString(manifest.description) ?? '',
|
|
324
|
+
version: pickString(manifest.version) ?? null,
|
|
325
|
+
source: 'custom-npm',
|
|
326
|
+
bundled: false,
|
|
327
|
+
custom: true,
|
|
328
|
+
installed: true,
|
|
329
|
+
enabled: enabledSkills.has(slug),
|
|
330
|
+
eligible: true,
|
|
331
|
+
scope: root.scope,
|
|
332
|
+
installPath,
|
|
333
|
+
package: pickString(installMeta.package) ?? null,
|
|
334
|
+
aliases,
|
|
335
|
+
actions: {
|
|
336
|
+
canToggle: true,
|
|
337
|
+
canUninstall: true,
|
|
338
|
+
canAttach: true
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return items;
|
|
344
|
+
}
|
|
345
|
+
async function readSkillManifest(skillDir) {
|
|
346
|
+
const skillJsonPath = path.join(skillDir, 'skill.json');
|
|
347
|
+
const packageJsonPath = path.join(skillDir, 'package.json');
|
|
348
|
+
if (await pathExists(skillJsonPath)) {
|
|
349
|
+
return await readJsonFile(skillJsonPath);
|
|
350
|
+
}
|
|
351
|
+
if (await pathExists(packageJsonPath)) {
|
|
352
|
+
return await readJsonFile(packageJsonPath);
|
|
353
|
+
}
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
async function readInstallMeta(skillDir) {
|
|
357
|
+
const installMetaPath = path.join(skillDir, '.openclaw-admin-bridge-install.json');
|
|
358
|
+
if (!(await pathExists(installMetaPath))) {
|
|
359
|
+
return {};
|
|
360
|
+
}
|
|
361
|
+
return await readJsonFile(installMetaPath);
|
|
362
|
+
}
|
|
363
|
+
function mergeSkillSources(officialSkills, customSkills) {
|
|
364
|
+
const merged = new Map();
|
|
365
|
+
for (const skill of officialSkills) {
|
|
366
|
+
const slug = typeof skill.slug === 'string' ? skill.slug : null;
|
|
367
|
+
if (slug) {
|
|
368
|
+
merged.set(slug, skill);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
for (const custom of customSkills) {
|
|
372
|
+
const existing = merged.get(custom.slug);
|
|
373
|
+
if (existing && isRecord(existing)) {
|
|
374
|
+
merged.set(custom.slug, {
|
|
375
|
+
...existing,
|
|
376
|
+
custom: true,
|
|
377
|
+
customInstallPath: custom.installPath,
|
|
378
|
+
customPackage: custom.package,
|
|
379
|
+
customAliases: custom.aliases,
|
|
380
|
+
actions: {
|
|
381
|
+
canToggle: true,
|
|
382
|
+
canUninstall: true,
|
|
383
|
+
canAttach: true
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
merged.set(custom.slug, custom);
|
|
389
|
+
}
|
|
390
|
+
return Array.from(merged.values()).sort((a, b) => {
|
|
391
|
+
const aSlug = isRecord(a) && typeof a.slug === 'string' ? a.slug : '';
|
|
392
|
+
const bSlug = isRecord(b) && typeof b.slug === 'string' ? b.slug : '';
|
|
393
|
+
return aSlug.localeCompare(bSlug);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async function getSkillState(context) {
|
|
397
|
+
const configPath = path.join(context.openclawRoot, 'openclaw.json');
|
|
398
|
+
if (!(await pathExists(configPath))) {
|
|
399
|
+
return {
|
|
400
|
+
enabledCustomSkills: new Set(),
|
|
401
|
+
allowBundled: null,
|
|
402
|
+
entriesEnabled: new Map()
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const config = await readJsonFile(configPath);
|
|
406
|
+
const skills = config.agents?.defaults?.skills;
|
|
407
|
+
const enabledCustomSkills = new Set(Array.isArray(skills)
|
|
408
|
+
? skills.filter((item) => typeof item === 'string')
|
|
409
|
+
: []);
|
|
410
|
+
const allowBundled = Array.isArray(config.skills?.allowBundled)
|
|
411
|
+
? new Set(config.skills?.allowBundled.filter((item) => typeof item === 'string'))
|
|
412
|
+
: null;
|
|
413
|
+
const entriesEnabled = new Map();
|
|
414
|
+
if (config.skills?.entries && typeof config.skills.entries === 'object') {
|
|
415
|
+
for (const [key, value] of Object.entries(config.skills.entries)) {
|
|
416
|
+
if (value && typeof value === 'object' && typeof value.enabled === 'boolean') {
|
|
417
|
+
entriesEnabled.set(key, value.enabled);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
enabledCustomSkills,
|
|
423
|
+
allowBundled,
|
|
424
|
+
entriesEnabled
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function isRecord(value) {
|
|
428
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
429
|
+
}
|
|
430
|
+
function isObject(value) {
|
|
431
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
432
|
+
}
|
|
433
|
+
function pickString(value) {
|
|
434
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
435
|
+
}
|
|
436
|
+
function pickBoolean(value) {
|
|
437
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
438
|
+
}
|
|
439
|
+
function invertBoolean(value) {
|
|
440
|
+
return value === undefined ? undefined : !value;
|
|
441
|
+
}
|
|
442
|
+
function resolveBundledEnabled(slug, skill, allowBundled) {
|
|
443
|
+
if (allowBundled === null) {
|
|
444
|
+
return (pickBoolean(skill.enabled)
|
|
445
|
+
?? pickBoolean(skill.active)
|
|
446
|
+
?? invertBoolean(pickBoolean(skill.disabled))
|
|
447
|
+
?? true);
|
|
448
|
+
}
|
|
449
|
+
return allowBundled.has(slug);
|
|
450
|
+
}
|
|
451
|
+
async function extractTarball(tarballPath, extractRoot) {
|
|
452
|
+
await execFileAsync('tar', ['-xzf', tarballPath, '-C', extractRoot]);
|
|
453
|
+
}
|
|
454
|
+
async function resolvePackedPackageRoot(extractRoot) {
|
|
455
|
+
const packageRoot = path.join(extractRoot, 'package');
|
|
456
|
+
if (await pathExists(packageRoot)) {
|
|
457
|
+
return packageRoot;
|
|
458
|
+
}
|
|
459
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'Packed npm artifact did not contain package/ root', {
|
|
460
|
+
extractRoot
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async function assertSkillPackage(packageRoot) {
|
|
464
|
+
if (!(await pathExists(path.join(packageRoot, 'SKILL.md')))) {
|
|
465
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'npm package is not a valid skill package: missing SKILL.md');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function resolveSkillIdentity(packageRoot, packageSpec) {
|
|
469
|
+
const skillJsonPath = path.join(packageRoot, 'skill.json');
|
|
470
|
+
let manifest = null;
|
|
471
|
+
if (await pathExists(skillJsonPath)) {
|
|
472
|
+
manifest = await readJsonFile(skillJsonPath);
|
|
473
|
+
}
|
|
474
|
+
const fallbackBase = packageSpec && packageSpec.trim().length > 0 ? packageSpec.trim() : packageSpec;
|
|
475
|
+
const fallbackSegment = extractSkillSegment(fallbackBase);
|
|
476
|
+
const slug = pickString(manifest?.slug) ?? sanitizeDirName(fallbackSegment);
|
|
477
|
+
const displayName = pickString(manifest?.name) ?? slug;
|
|
478
|
+
const dirName = sanitizeDirName(slug);
|
|
479
|
+
return { slug, displayName, dirName };
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* 从包名中提取用于推断 slug 的片段:
|
|
483
|
+
* - scoped 包(@foo/bar)→ 取 scope 部分 foo(与 OpenClaw CLI 行为一致)
|
|
484
|
+
* - 普通带斜杠包(foo/bar)→ 取最后段 bar
|
|
485
|
+
* - 无斜杠(foo)→ 原样返回
|
|
486
|
+
*/
|
|
487
|
+
function extractSkillSegment(packageName) {
|
|
488
|
+
if (packageName.startsWith('@') && packageName.includes('/')) {
|
|
489
|
+
// @foo/bar → foo
|
|
490
|
+
return packageName.split('/')[0].replace(/^@/, '');
|
|
491
|
+
}
|
|
492
|
+
if (packageName.includes('/')) {
|
|
493
|
+
// foo/bar → bar
|
|
494
|
+
return packageName.split('/').at(-1) ?? packageName;
|
|
495
|
+
}
|
|
496
|
+
return packageName;
|
|
497
|
+
}
|
|
498
|
+
function sanitizeDirName(value) {
|
|
499
|
+
return value
|
|
500
|
+
.replace(/^@/, '')
|
|
501
|
+
.replace(/[\\/]/g, '-')
|
|
502
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
503
|
+
.replace(/-+/g, '-')
|
|
504
|
+
.replace(/^-|-$/g, '')
|
|
505
|
+
.toLowerCase();
|
|
506
|
+
}
|
|
507
|
+
function buildCustomSkillAliases(slug, displayName, dirName, packageName) {
|
|
508
|
+
const values = new Set();
|
|
509
|
+
const add = (value) => {
|
|
510
|
+
if (!value)
|
|
511
|
+
return;
|
|
512
|
+
const trimmed = value.trim();
|
|
513
|
+
if (!trimmed)
|
|
514
|
+
return;
|
|
515
|
+
values.add(trimmed);
|
|
516
|
+
};
|
|
517
|
+
add(slug);
|
|
518
|
+
add(displayName);
|
|
519
|
+
add(dirName);
|
|
520
|
+
add(sanitizeDirName(dirName));
|
|
521
|
+
add(packageName);
|
|
522
|
+
if (packageName) {
|
|
523
|
+
const lastSegment = packageName.includes('/') ? (packageName.split('/').at(-1) ?? packageName) : packageName;
|
|
524
|
+
add(lastSegment);
|
|
525
|
+
add(sanitizeDirName(lastSegment));
|
|
526
|
+
}
|
|
527
|
+
return Array.from(values);
|
|
528
|
+
}
|