@spzhongwin/skill-logger-plugin 1.0.17 → 1.0.19
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/dist/index.js +1048 -165
- package/openclaw.plugin.json +5 -0
- package/package.json +1 -1
- package/src/free-skill-directory.test.ts +109 -0
- package/src/free-skill-directory.ts +188 -0
- package/src/free-skill-tool.test.ts +68 -0
- package/src/free-skill-tool.ts +99 -0
- package/src/free-skill-workspace.test.ts +109 -0
- package/src/free-skill-workspace.ts +223 -0
- package/src/free-skill-writer.test.ts +105 -0
- package/src/free-skill-writer.ts +591 -0
- package/src/index.ts +5 -0
- package/src/ws-client.test.ts +162 -0
- package/src/ws-client.ts +227 -95
package/src/ws-client.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import {
|
|
8
|
+
GatewayWsClient,
|
|
8
9
|
normalizeAssistantUserId,
|
|
9
10
|
normalizeCommandCode,
|
|
10
11
|
enabledAgentIdsFromAccounts,
|
|
@@ -13,8 +14,155 @@ import {
|
|
|
13
14
|
readCronJobsByAgentId,
|
|
14
15
|
findInstalledExpertSkillsRoot,
|
|
15
16
|
shouldSyncBuiltInTemplate,
|
|
17
|
+
resolveGatewaySkillTarget,
|
|
18
|
+
isGatewaySkillCommand,
|
|
16
19
|
} from "./ws-client.ts";
|
|
17
20
|
|
|
21
|
+
async function handleClientMessage(
|
|
22
|
+
client: GatewayWsClient,
|
|
23
|
+
message: Record<string, unknown>,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
await (client as unknown as { handleMessage(value: Record<string, unknown>): Promise<void> }).handleMessage(message);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createReplyingClient(gatewayId: string): { client: GatewayWsClient; replies: Record<string, unknown>[] } {
|
|
29
|
+
const replies: Record<string, unknown>[] = [];
|
|
30
|
+
const client = new GatewayWsClient({
|
|
31
|
+
serverUrl: "ws://localhost:1",
|
|
32
|
+
gatewayId,
|
|
33
|
+
updater: {} as never,
|
|
34
|
+
});
|
|
35
|
+
const socket = {
|
|
36
|
+
readyState: 1,
|
|
37
|
+
send(payload: string, callback?: (error?: Error) => void) {
|
|
38
|
+
replies.push(JSON.parse(payload) as Record<string, unknown>);
|
|
39
|
+
callback?.();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
Object.assign(client as object, { ws: socket });
|
|
43
|
+
return { client, replies };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("LIST_FREE_SKILLS", () => {
|
|
47
|
+
it("通过 action 分发扫描当前 workspace,并返回来源明确的路径字段", async () => {
|
|
48
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-ws-client-"));
|
|
49
|
+
try {
|
|
50
|
+
const skillPath = path.join(workspace, ".xg-platform", "demo");
|
|
51
|
+
fs.mkdirSync(skillPath, { recursive: true });
|
|
52
|
+
fs.writeFileSync(
|
|
53
|
+
path.join(skillPath, "SKILL.md"),
|
|
54
|
+
"---\nname: Demo\ndescription: local demo\nversion: 1.2.3\n---\n",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const { client, replies } = createReplyingClient("gateway-local-1");
|
|
58
|
+
Object.assign(client as object, {
|
|
59
|
+
resolveRegularSkillContext: async () => ({
|
|
60
|
+
skillsDir: path.join(workspace, "skills"),
|
|
61
|
+
localAgentId: "sales-agent",
|
|
62
|
+
workspace,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
await handleClientMessage(client, {
|
|
66
|
+
action: "LIST_FREE_SKILLS",
|
|
67
|
+
userId: "12345",
|
|
68
|
+
agentId: "injected-agent-must-be-ignored",
|
|
69
|
+
workspace: path.join(workspace, "..", "external-workspace"),
|
|
70
|
+
replyId: "free-1",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const hostSkillFilePath = fs.realpathSync(path.join(skillPath, "SKILL.md"));
|
|
74
|
+
assert.deepEqual(replies, [{
|
|
75
|
+
type: "REPLY",
|
|
76
|
+
replyId: "free-1",
|
|
77
|
+
success: true,
|
|
78
|
+
action: "LIST_FREE_SKILLS",
|
|
79
|
+
data: {
|
|
80
|
+
gatewayId: "gateway-local-1",
|
|
81
|
+
agentId: "sales-agent",
|
|
82
|
+
directoryPath: fs.realpathSync(path.join(workspace, ".xg-platform")),
|
|
83
|
+
skills: [{
|
|
84
|
+
code: "demo",
|
|
85
|
+
name: "Demo",
|
|
86
|
+
description: "local demo",
|
|
87
|
+
version: "1.2.3",
|
|
88
|
+
workspaceRelativePath: path.join(".xg-platform", "demo"),
|
|
89
|
+
skillFilePath: path.join(".xg-platform", "demo", "SKILL.md"),
|
|
90
|
+
hostSkillFilePath,
|
|
91
|
+
}],
|
|
92
|
+
},
|
|
93
|
+
}]);
|
|
94
|
+
} finally {
|
|
95
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("忽略请求注入的外部 workspace 和 agentId,只使用解析出的 Agent workspace", async () => {
|
|
100
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-resolved-ws-"));
|
|
101
|
+
const externalWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-injected-ws-"));
|
|
102
|
+
try {
|
|
103
|
+
const safeSkillPath = path.join(workspace, ".xg-platform", "safe");
|
|
104
|
+
fs.mkdirSync(safeSkillPath, { recursive: true });
|
|
105
|
+
fs.writeFileSync(path.join(safeSkillPath, "SKILL.md"), "---\nname: Safe\n---\n");
|
|
106
|
+
|
|
107
|
+
const externalSkillPath = path.join(externalWorkspace, ".xg-platform", "outside");
|
|
108
|
+
fs.mkdirSync(externalSkillPath, { recursive: true });
|
|
109
|
+
fs.writeFileSync(path.join(externalSkillPath, "SKILL.md"), "---\nname: Outside\n---\n");
|
|
110
|
+
|
|
111
|
+
const { client, replies } = createReplyingClient("gateway-local-2");
|
|
112
|
+
Object.assign(client as object, {
|
|
113
|
+
resolveRegularSkillContext: async () => ({
|
|
114
|
+
skillsDir: path.join(workspace, "skills"),
|
|
115
|
+
localAgentId: "resolved-agent",
|
|
116
|
+
workspace,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
await handleClientMessage(client, {
|
|
120
|
+
action: "LIST_FREE_SKILLS",
|
|
121
|
+
userId: "12345",
|
|
122
|
+
agentId: "injected-agent",
|
|
123
|
+
workspace: externalWorkspace,
|
|
124
|
+
workspacePath: externalWorkspace,
|
|
125
|
+
agentWorkspace: externalWorkspace,
|
|
126
|
+
replyId: "free-isolated",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const response = replies[0] as { data: Record<string, unknown> };
|
|
130
|
+
assert.equal(response.data.agentId, "resolved-agent");
|
|
131
|
+
assert.equal(response.data.directoryPath, fs.realpathSync(path.join(workspace, ".xg-platform")));
|
|
132
|
+
assert.deepEqual(response.data.skills, [{
|
|
133
|
+
code: "safe",
|
|
134
|
+
name: "Safe",
|
|
135
|
+
description: "",
|
|
136
|
+
version: "",
|
|
137
|
+
workspaceRelativePath: path.join(".xg-platform", "safe"),
|
|
138
|
+
skillFilePath: path.join(".xg-platform", "safe", "SKILL.md"),
|
|
139
|
+
hostSkillFilePath: fs.realpathSync(path.join(safeSkillPath, "SKILL.md")),
|
|
140
|
+
}]);
|
|
141
|
+
} finally {
|
|
142
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
143
|
+
fs.rmSync(externalWorkspace, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("缺少 userId 时返回明确错误,不静默丢弃新 action", async () => {
|
|
148
|
+
const { client, replies } = createReplyingClient("gateway-local-1");
|
|
149
|
+
|
|
150
|
+
await handleClientMessage(client, {
|
|
151
|
+
action: "LIST_FREE_SKILLS",
|
|
152
|
+
replyId: "free-missing-user",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(replies, [{
|
|
156
|
+
type: "REPLY",
|
|
157
|
+
replyId: "free-missing-user",
|
|
158
|
+
success: false,
|
|
159
|
+
error: "INVALID_REQUEST",
|
|
160
|
+
message: "Missing userId parameter",
|
|
161
|
+
action: "LIST_FREE_SKILLS",
|
|
162
|
+
}]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
18
166
|
describe("command code boundary", () => {
|
|
19
167
|
it("只接受单一路径段,不静默改写路径穿越输入", () => {
|
|
20
168
|
assert.equal(normalizeCommandCode("demo-skill"), "demo-skill");
|
|
@@ -156,6 +304,20 @@ describe("built-in template update boundary", () => {
|
|
|
156
304
|
});
|
|
157
305
|
});
|
|
158
306
|
|
|
307
|
+
describe("Gateway 公共 Skill 安装边界", () => {
|
|
308
|
+
it("固定安装到 OpenClaw 顶层 skills,不接受指令传入任意目录", () => {
|
|
309
|
+
assert.equal(resolveGatewaySkillTarget("/var/openclaw"), "/var/openclaw/skills");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("仅允许 Skill 安装、更新和卸载使用 gateway 作用域", () => {
|
|
313
|
+
assert.equal(isGatewaySkillCommand("INSTALL_SKILL", "gateway"), true);
|
|
314
|
+
assert.equal(isGatewaySkillCommand("UPDATE_SKILL", "gateway"), true);
|
|
315
|
+
assert.equal(isGatewaySkillCommand("UNINSTALL_SKILL", "gateway"), true);
|
|
316
|
+
assert.equal(isGatewaySkillCommand("LIST_SKILLS", "gateway"), false);
|
|
317
|
+
assert.equal(isGatewaySkillCommand("UPDATE_SKILL", "agent"), false);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
159
321
|
describe("expert skill update target", () => {
|
|
160
322
|
it("返回 .user/skills 父目录,由安装器统一追加 skill code", async () => {
|
|
161
323
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-expert-skill-"));
|
package/src/ws-client.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
5
5
|
import { SkillUpdater } from "./updater.ts";
|
|
6
6
|
import { openclawHome } from "./paths.ts";
|
|
7
7
|
import { readSkillVersion } from "./skill-version.ts";
|
|
8
|
+
import { scanFreeSkillDirectory, type FreeSkill } from "./free-skill-directory.ts";
|
|
8
9
|
|
|
9
10
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
10
11
|
const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
|
|
@@ -140,6 +141,15 @@ export function shouldSyncBuiltInTemplate(action: string, isBuiltIn: unknown): b
|
|
|
140
141
|
return action === "UPDATE_SKILL" && isBuiltIn === true;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
export function resolveGatewaySkillTarget(home = openclawHome()): string {
|
|
145
|
+
return path.join(home, "skills");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isGatewaySkillCommand(action: unknown, installScope: unknown): boolean {
|
|
149
|
+
return installScope === "gateway"
|
|
150
|
+
&& ["INSTALL_SKILL", "UPDATE_SKILL", "UNINSTALL_SKILL"].includes(String(action || ""));
|
|
151
|
+
}
|
|
152
|
+
|
|
143
153
|
export async function findInstalledExpertSkillsRoot(
|
|
144
154
|
rootPath: string,
|
|
145
155
|
pureId: string,
|
|
@@ -175,6 +185,73 @@ export function readCronJobsByAgentId(
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
export type FreeSkillDirectorySkill = Pick<FreeSkill, "code" | "name" | "description" | "version"> & {
|
|
189
|
+
workspaceRelativePath: string;
|
|
190
|
+
skillFilePath: string;
|
|
191
|
+
hostSkillFilePath: string;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type FreeSkillDirectoryResponse = {
|
|
195
|
+
gatewayId: string;
|
|
196
|
+
agentId: string;
|
|
197
|
+
directoryPath: string;
|
|
198
|
+
skills: FreeSkillDirectorySkill[];
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function isWithinPath(parent: string, candidate: string): boolean {
|
|
202
|
+
const relative = path.relative(parent, candidate);
|
|
203
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function relativeWorkspacePath(workspace: string, target: string): string {
|
|
207
|
+
const relative = path.relative(workspace, target);
|
|
208
|
+
return isWithinPath(workspace, target) ? relative : "";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function resolveRealWorkspace(workspace: string): Promise<string> {
|
|
212
|
+
const resolved = path.resolve(workspace);
|
|
213
|
+
try {
|
|
214
|
+
return await fs.realpath(resolved);
|
|
215
|
+
} catch {
|
|
216
|
+
return resolved;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function resolveFreeSkillDirectoryPath(workspace: string): Promise<string> {
|
|
221
|
+
const workspacePath = await resolveRealWorkspace(workspace);
|
|
222
|
+
const candidate = path.resolve(workspacePath, ".xg-platform");
|
|
223
|
+
try {
|
|
224
|
+
const directoryPath = await fs.realpath(candidate);
|
|
225
|
+
const stat = await fs.stat(directoryPath);
|
|
226
|
+
return stat.isDirectory() && isWithinPath(workspacePath, directoryPath) ? directoryPath : "";
|
|
227
|
+
} catch {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildFreeSkillDirectoryResponse(
|
|
233
|
+
gatewayId: string,
|
|
234
|
+
agentId: string,
|
|
235
|
+
workspacePath: string,
|
|
236
|
+
directoryPath: string,
|
|
237
|
+
skills: FreeSkill[],
|
|
238
|
+
): FreeSkillDirectoryResponse {
|
|
239
|
+
return {
|
|
240
|
+
gatewayId,
|
|
241
|
+
agentId,
|
|
242
|
+
directoryPath,
|
|
243
|
+
skills: skills.map((skill) => ({
|
|
244
|
+
code: skill.code,
|
|
245
|
+
name: skill.name,
|
|
246
|
+
description: skill.description,
|
|
247
|
+
version: skill.version,
|
|
248
|
+
workspaceRelativePath: relativeWorkspacePath(workspacePath, skill.skillPath),
|
|
249
|
+
skillFilePath: relativeWorkspacePath(workspacePath, skill.skillFilePath),
|
|
250
|
+
hostSkillFilePath: skill.skillFilePath,
|
|
251
|
+
})),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
178
255
|
export interface WsClientOptions {
|
|
179
256
|
serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
|
|
180
257
|
authToken?: string; // 用于网关鉴权
|
|
@@ -230,16 +307,28 @@ export class GatewayWsClient {
|
|
|
230
307
|
};
|
|
231
308
|
}
|
|
232
309
|
|
|
233
|
-
|
|
234
|
-
private async resolveRegularSkillTarget(userId: string): Promise<string> {
|
|
310
|
+
private async readOpenclawConfig(): Promise<unknown> {
|
|
235
311
|
const configPath = path.join(openclawHome(), "openclaw.json");
|
|
236
|
-
let config: unknown;
|
|
237
312
|
try {
|
|
238
|
-
|
|
313
|
+
return JSON.parse(await fs.readFile(configPath, "utf-8"));
|
|
239
314
|
} catch (err: any) {
|
|
240
315
|
throw new Error(`无法读取 openclaw.json: ${err?.message || String(err)}`);
|
|
241
316
|
}
|
|
242
|
-
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async resolveRegularSkillContext(userId: string): Promise<SkillInstallTarget & { workspace: string }> {
|
|
320
|
+
const target = resolveSkillInstallTarget(await this.readOpenclawConfig(), userId);
|
|
321
|
+
return { ...target, workspace: path.dirname(target.skillsDir) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 普通 Skill 的所有读写操作共用这一个配置驱动的寻址入口。 */
|
|
325
|
+
private async resolveRegularSkillTarget(userId: string): Promise<string> {
|
|
326
|
+
return (await this.resolveRegularSkillContext(userId)).skillsDir;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async resolveFreeSkillRequestContext(userId: string): Promise<{ workspace: string; agentId: string }> {
|
|
330
|
+
const target = await this.resolveRegularSkillContext(userId);
|
|
331
|
+
return { workspace: target.workspace, agentId: target.localAgentId };
|
|
243
332
|
}
|
|
244
333
|
|
|
245
334
|
constructor(options: WsClientOptions) {
|
|
@@ -505,6 +594,7 @@ export class GatewayWsClient {
|
|
|
505
594
|
agentIds: Array.from(this.currentAgentIds),
|
|
506
595
|
clientTime: Date.now(),
|
|
507
596
|
supportsBatch: true,
|
|
597
|
+
supportsGatewaySkillScope: true,
|
|
508
598
|
}, "Heartbeat");
|
|
509
599
|
}
|
|
510
600
|
|
|
@@ -537,7 +627,7 @@ export class GatewayWsClient {
|
|
|
537
627
|
* 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
|
|
538
628
|
*/
|
|
539
629
|
private async handleMessage(msg: any) {
|
|
540
|
-
const { action, userId, code, url, force, version, replyId, isBuiltIn } = msg;
|
|
630
|
+
const { action, userId, code, url, force, version, replyId, isBuiltIn, installScope } = msg;
|
|
541
631
|
this.appendLogToFile("INFO", "Command", `Received WS message`, {
|
|
542
632
|
action,
|
|
543
633
|
userId,
|
|
@@ -545,6 +635,7 @@ export class GatewayWsClient {
|
|
|
545
635
|
version,
|
|
546
636
|
replyId,
|
|
547
637
|
isBuiltIn,
|
|
638
|
+
installScope,
|
|
548
639
|
hasDirectUrl: Boolean(url),
|
|
549
640
|
});
|
|
550
641
|
|
|
@@ -577,6 +668,14 @@ export class GatewayWsClient {
|
|
|
577
668
|
|
|
578
669
|
if (!userId) {
|
|
579
670
|
this.appendLogToFile("WARN", "Command", `Message dropped: missing userId`, msg);
|
|
671
|
+
if (action === "LIST_FREE_SKILLS") {
|
|
672
|
+
this.reply(replyId, {
|
|
673
|
+
success: false,
|
|
674
|
+
error: "INVALID_REQUEST",
|
|
675
|
+
message: "Missing userId parameter",
|
|
676
|
+
action,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
580
679
|
return;
|
|
581
680
|
}
|
|
582
681
|
|
|
@@ -587,16 +686,19 @@ export class GatewayWsClient {
|
|
|
587
686
|
return;
|
|
588
687
|
}
|
|
589
688
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
689
|
+
const gatewaySkillCommand = isGatewaySkillCommand(action, installScope);
|
|
690
|
+
// Gateway 公共 Skill 命令不依赖 Agent。其余命令继续执行严格的用户目录寻址。
|
|
691
|
+
const pureId = gatewaySkillCommand ? undefined : normalizeAssistantUserId(userId);
|
|
692
|
+
if (!gatewaySkillCommand && !pureId) {
|
|
593
693
|
this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
|
|
594
694
|
return;
|
|
595
695
|
}
|
|
596
696
|
try {
|
|
597
697
|
if (action === "INSTALL_SKILL") {
|
|
598
698
|
if (!safeCode) throw new Error("Missing code parameter");
|
|
599
|
-
const targetDir =
|
|
699
|
+
const targetDir = gatewaySkillCommand
|
|
700
|
+
? resolveGatewaySkillTarget()
|
|
701
|
+
: await this.resolveRegularSkillTarget(userId);
|
|
600
702
|
console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
|
|
601
703
|
this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
|
|
602
704
|
const result = await this.options.updater.manualInstall({
|
|
@@ -607,113 +709,126 @@ export class GatewayWsClient {
|
|
|
607
709
|
targetDir,
|
|
608
710
|
trace: this.createInstallTrace({ action, replyId, userId, code: safeCode }),
|
|
609
711
|
});
|
|
610
|
-
this.reply(replyId, {
|
|
712
|
+
this.reply(replyId, {
|
|
713
|
+
success: result.success, message: result.message, action,
|
|
714
|
+
data: {code: safeCode, version, installScope: gatewaySkillCommand ? "gateway" : "agent"},
|
|
715
|
+
});
|
|
611
716
|
|
|
612
717
|
} else if (action === "UNINSTALL_SKILL") {
|
|
613
718
|
if (!safeCode) throw new Error("Missing code parameter");
|
|
614
|
-
const targetDir =
|
|
719
|
+
const targetDir = gatewaySkillCommand
|
|
720
|
+
? resolveGatewaySkillTarget()
|
|
721
|
+
: await this.resolveRegularSkillTarget(userId);
|
|
615
722
|
console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
|
|
616
723
|
this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
|
|
617
724
|
const skillPath = path.join(targetDir, safeCode);
|
|
618
725
|
await fs.rm(skillPath, { recursive: true, force: true });
|
|
619
|
-
this.reply(replyId, {
|
|
726
|
+
this.reply(replyId, {
|
|
727
|
+
success: true, message: `Skill ${safeCode} removed`, action,
|
|
728
|
+
data: {code: safeCode, installScope: gatewaySkillCommand ? "gateway" : "agent", removed: true},
|
|
729
|
+
});
|
|
620
730
|
|
|
621
|
-
} else if (action === "
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
731
|
+
} else if (action === "LIST_FREE_SKILLS") {
|
|
732
|
+
const context = await this.resolveFreeSkillRequestContext(userId);
|
|
733
|
+
const workspacePath = await resolveRealWorkspace(context.workspace);
|
|
734
|
+
const scannedSkills = await scanFreeSkillDirectory(context.workspace);
|
|
735
|
+
const directoryPath = scannedSkills[0]?.directoryPath
|
|
736
|
+
|| await resolveFreeSkillDirectoryPath(context.workspace);
|
|
737
|
+
const data = buildFreeSkillDirectoryResponse(
|
|
738
|
+
this.options.gatewayId,
|
|
739
|
+
context.agentId,
|
|
740
|
+
workspacePath,
|
|
741
|
+
directoryPath,
|
|
742
|
+
scannedSkills,
|
|
743
|
+
);
|
|
744
|
+
this.reply(replyId, { success: true, data, action });
|
|
634
745
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
746
|
+
} else if (action === "LIST_SKILLS") {
|
|
747
|
+
const regularTargetDir = await this.resolveRegularSkillTarget(userId);
|
|
748
|
+
|
|
749
|
+
// 用户视角的有效 Skill = Agent workspace Skill + Gateway 顶层公共 Skill。
|
|
750
|
+
// 同 code 时公共内置版本后写覆盖,确保不会被快照差分误判为缺失。
|
|
751
|
+
const skillsByCode = new Map<string, any>();
|
|
752
|
+
const sources = [
|
|
753
|
+
{dir: regularTargetDir, gatewayBuiltIn: false},
|
|
754
|
+
{dir: resolveGatewaySkillTarget(), gatewayBuiltIn: true},
|
|
755
|
+
];
|
|
756
|
+
for (const source of sources) {
|
|
757
|
+
let entries;
|
|
643
758
|
try {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
continue;
|
|
759
|
+
entries = await fs.readdir(source.dir, {withFileTypes: true});
|
|
760
|
+
} catch (err: any) {
|
|
761
|
+
// 新用户的 Agent 私有目录尚未创建时,仍必须返回 Gateway 公共内置 Skill。
|
|
762
|
+
if (err?.code === "ENOENT") continue;
|
|
763
|
+
throw err;
|
|
648
764
|
}
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
try {
|
|
659
|
-
const mdContent = await fs.readFile(skillMdPath, 'utf8');
|
|
660
|
-
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
|
|
661
|
-
const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
|
|
662
|
-
if (parsedName) name = parsedName;
|
|
663
|
-
|
|
664
|
-
const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
|
|
665
|
-
if (descMatch && descMatch[2]) {
|
|
666
|
-
description = descMatch[2].replace(/\n\s+/g, ' ').trim();
|
|
765
|
+
const dirs = entries.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."));
|
|
766
|
+
for (const e of dirs) {
|
|
767
|
+
const skillDir = path.join(source.dir, e.name);
|
|
768
|
+
const skillMdPath = path.join(skillDir, "SKILL.md");
|
|
769
|
+
try {
|
|
770
|
+
const stat = await fs.stat(skillMdPath);
|
|
771
|
+
if (!stat.isFile()) continue;
|
|
772
|
+
} catch {
|
|
773
|
+
continue;
|
|
667
774
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
775
|
+
|
|
776
|
+
const metaPath = path.join(skillDir, ".meta.json");
|
|
777
|
+
let isPlatform = source.gatewayBuiltIn;
|
|
778
|
+
let isBuiltIn = source.gatewayBuiltIn || e.isSymbolicLink();
|
|
779
|
+
let metaData: any = null;
|
|
780
|
+
let name = e.name;
|
|
781
|
+
let description = "";
|
|
782
|
+
let skillVersion = "";
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const mdContent = await fs.readFile(skillMdPath, "utf8");
|
|
786
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
|
|
787
|
+
const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
|
|
788
|
+
if (parsedName) name = parsedName;
|
|
789
|
+
const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
|
|
790
|
+
if (descMatch?.[2]) description = descMatch[2].replace(/\n\s+/g, " ").trim();
|
|
791
|
+
} catch {}
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
const parsed = JSON.parse(await fs.readFile(metaPath, "utf8"));
|
|
795
|
+
if (parsed) {
|
|
796
|
+
if (parsed.ownerId === "CMS" || parsed.ownerId === "CMS_COMPAT") isPlatform = true;
|
|
797
|
+
if (parsed.isBuiltIn === true || parsed.ownerId === "built-in") isBuiltIn = true;
|
|
798
|
+
metaData = parsed;
|
|
799
|
+
}
|
|
800
|
+
} catch {}
|
|
801
|
+
|
|
802
|
+
const resolvedVersion = await readSkillVersion(skillDir);
|
|
803
|
+
if (resolvedVersion) skillVersion = resolvedVersion;
|
|
804
|
+
|
|
805
|
+
if (isPlatform) {
|
|
806
|
+
skillsByCode.set(e.name, {
|
|
807
|
+
code: e.name, isPlatform: true, isBuiltIn, version: skillVersion,
|
|
808
|
+
name, description, publishedAt: metaData?.publishedAt,
|
|
809
|
+
});
|
|
810
|
+
} else {
|
|
811
|
+
skillsByCode.set(e.name, {
|
|
812
|
+
code: e.name, isPlatform: false, isBuiltIn, version: skillVersion,
|
|
813
|
+
name, description,
|
|
814
|
+
});
|
|
677
815
|
}
|
|
678
|
-
} catch (err) {}
|
|
679
|
-
|
|
680
|
-
const resolvedVersion = await readSkillVersion(skillDir);
|
|
681
|
-
if (resolvedVersion) skillVersion = resolvedVersion;
|
|
682
|
-
|
|
683
|
-
if (isPlatform) {
|
|
684
|
-
list.push({
|
|
685
|
-
code: e.name,
|
|
686
|
-
isPlatform: true,
|
|
687
|
-
isBuiltIn: isBuiltIn,
|
|
688
|
-
version: skillVersion,
|
|
689
|
-
name,
|
|
690
|
-
description,
|
|
691
|
-
publishedAt: metaData?.publishedAt
|
|
692
|
-
});
|
|
693
|
-
} else {
|
|
694
|
-
list.push({
|
|
695
|
-
code: e.name,
|
|
696
|
-
isPlatform: false,
|
|
697
|
-
isBuiltIn: isBuiltIn,
|
|
698
|
-
version: skillVersion,
|
|
699
|
-
name: name,
|
|
700
|
-
description: description
|
|
701
|
-
});
|
|
702
816
|
}
|
|
703
817
|
}
|
|
704
|
-
this.reply(replyId, { success: true, data:
|
|
818
|
+
this.reply(replyId, { success: true, data: [...skillsByCode.values()], action });
|
|
705
819
|
|
|
706
820
|
} else if (action === "UPDATE_SKILL") {
|
|
707
821
|
if (!safeCode) throw new Error("Missing code parameter");
|
|
708
822
|
// 更新可能是大批量广播,增加防阻塞的随机延时 (0~5秒)
|
|
709
823
|
const delayMs = Math.random() * 5000;
|
|
710
|
-
const syncBuiltInTemplate = shouldSyncBuiltInTemplate(action, isBuiltIn);
|
|
824
|
+
const syncBuiltInTemplate = !gatewaySkillCommand && shouldSyncBuiltInTemplate(action, isBuiltIn);
|
|
711
825
|
console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
|
|
712
826
|
this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, {
|
|
713
827
|
userId,
|
|
714
828
|
code: safeCode,
|
|
715
829
|
version,
|
|
716
830
|
isBuiltIn,
|
|
831
|
+
installScope,
|
|
717
832
|
syncBuiltInTemplate,
|
|
718
833
|
delayMs: Math.round(delayMs),
|
|
719
834
|
});
|
|
@@ -721,13 +836,17 @@ export class GatewayWsClient {
|
|
|
721
836
|
setTimeout(async () => {
|
|
722
837
|
try {
|
|
723
838
|
// 延时期间配置可能变化;在真正写盘前重新校验 enabled 与 workspace。
|
|
724
|
-
const targetDir =
|
|
839
|
+
const targetDir = gatewaySkillCommand
|
|
840
|
+
? resolveGatewaySkillTarget()
|
|
841
|
+
: await this.resolveRegularSkillTarget(userId);
|
|
725
842
|
const additionalTargetDirs: string[] = syncBuiltInTemplate
|
|
726
843
|
? [path.join(openclawHome(), "workspace-xgjk-assistant-template", "skills")]
|
|
727
844
|
: [];
|
|
728
845
|
|
|
729
846
|
// 同步更新用户的 expert skill 目录
|
|
730
|
-
const userSkillRoot =
|
|
847
|
+
const userSkillRoot = pureId
|
|
848
|
+
? await findInstalledExpertSkillsRoot(openclawHome(), pureId, safeCode)
|
|
849
|
+
: undefined;
|
|
731
850
|
if (userSkillRoot) additionalTargetDirs.push(userSkillRoot);
|
|
732
851
|
const result = await this.options.updater.manualInstall({
|
|
733
852
|
code: safeCode,
|
|
@@ -754,7 +873,10 @@ export class GatewayWsClient {
|
|
|
754
873
|
syncBuiltInTemplate,
|
|
755
874
|
});
|
|
756
875
|
if (replyId) {
|
|
757
|
-
this.reply(replyId, {
|
|
876
|
+
this.reply(replyId, {
|
|
877
|
+
success: result.success, message: result.message, action,
|
|
878
|
+
data: {code: safeCode, version, installScope: gatewaySkillCommand ? "gateway" : "agent"},
|
|
879
|
+
});
|
|
758
880
|
}
|
|
759
881
|
} catch (e: any) {
|
|
760
882
|
this.appendLogToFile("ERROR", "Command", `UPDATE_SKILL threw`, {
|
|
@@ -880,7 +1002,17 @@ export class GatewayWsClient {
|
|
|
880
1002
|
} else {
|
|
881
1003
|
console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
|
|
882
1004
|
this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
|
|
883
|
-
|
|
1005
|
+
if (action === "LIST_FREE_SKILLS") {
|
|
1006
|
+
this.reply(replyId, {
|
|
1007
|
+
success: false,
|
|
1008
|
+
unsupported: true,
|
|
1009
|
+
error: "UNSUPPORTED_ACTION",
|
|
1010
|
+
message: "LIST_FREE_SKILLS unsupported by this plugin",
|
|
1011
|
+
action,
|
|
1012
|
+
});
|
|
1013
|
+
} else {
|
|
1014
|
+
this.reply(replyId, { success: false, message: `Unknown action: ${action}`, action });
|
|
1015
|
+
}
|
|
884
1016
|
}
|
|
885
1017
|
} catch (err: any) {
|
|
886
1018
|
this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
|