@spzhongwin/skill-logger-plugin 1.0.16 → 1.0.18
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 +2902 -281
- package/openclaw.plugin.json +50 -50
- package/package.json +34 -34
- package/src/active-skills.test.ts +32 -32
- package/src/active-skills.ts +77 -77
- package/src/config-sync.test.ts +165 -165
- package/src/config-sync.ts +544 -544
- package/src/expert-skill-layout.test.ts +196 -196
- package/src/expert-skill-layout.ts +233 -233
- package/src/hooks.test.ts +228 -228
- package/src/hooks.ts +494 -494
- package/src/http.ts +61 -61
- package/src/identity.ts +88 -88
- package/src/index.test.ts +53 -53
- package/src/index.ts +218 -218
- package/src/integration.test.ts +119 -119
- package/src/matcher.test.ts +170 -170
- package/src/matcher.ts +393 -393
- package/src/paths.test.ts +57 -57
- package/src/paths.ts +84 -84
- package/src/reporter.test.ts +139 -139
- package/src/reporter.ts +303 -303
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +33 -33
- package/src/semver.ts +65 -65
- package/src/skill-version.ts +53 -53
- package/src/types.ts +202 -202
- package/src/updater.test.ts +431 -431
- package/src/updater.ts +584 -584
- package/src/ws-client.test.ts +263 -158
- package/src/ws-client.ts +936 -805
- package/test-ws.ts +17 -17
- package/tsconfig.json +18 -18
|
@@ -1,233 +1,233 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import type { Dirent } from "node:fs";
|
|
5
|
-
import { compareVersions, isComparableVersion } from "./semver.ts";
|
|
6
|
-
import { readSkillVersion } from "./skill-version.ts";
|
|
7
|
-
|
|
8
|
-
const ASSISTANT_WORKSPACE_RE = /^workspace-assistant-\d{5,}$/;
|
|
9
|
-
const REPAIR_RESIDUE_RE = /^\.(.+)\.repair-(old|new)-(.+)$/;
|
|
10
|
-
|
|
11
|
-
export type ExpertSkillLayoutRepairResult = {
|
|
12
|
-
scannedWorkspaces: number;
|
|
13
|
-
repaired: string[];
|
|
14
|
-
skipped: string[];
|
|
15
|
-
errors: Array<{ path: string; message: string }>;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
async function lstatOrUndefined(filePath: string) {
|
|
19
|
-
try {
|
|
20
|
-
return await fs.lstat(filePath);
|
|
21
|
-
} catch (error: any) {
|
|
22
|
-
if (error?.code === "ENOENT") return undefined;
|
|
23
|
-
throw error;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function isRegularFile(filePath: string): Promise<boolean> {
|
|
28
|
-
const stat = await lstatOrUndefined(filePath);
|
|
29
|
-
return Boolean(stat?.isFile() && !stat.isSymbolicLink());
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function removeRealDirectory(dirPath: string): Promise<void> {
|
|
33
|
-
const stat = await lstatOrUndefined(dirPath);
|
|
34
|
-
if (!stat) return;
|
|
35
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
36
|
-
throw new Error(`拒绝清理非真实目录: ${dirPath}`);
|
|
37
|
-
}
|
|
38
|
-
await fs.rm(dirPath, { recursive: true });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function removeNestedDirectoryIfPresent(dirPath: string): Promise<void> {
|
|
42
|
-
const stat = await lstatOrUndefined(dirPath);
|
|
43
|
-
if (!stat) return;
|
|
44
|
-
if (stat.isSymbolicLink()) throw new Error(`拒绝清理符号链接: ${dirPath}`);
|
|
45
|
-
if (stat.isDirectory()) await fs.rm(dirPath, { recursive: true });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function recoverRepairResidues(
|
|
49
|
-
skillsRoot: string,
|
|
50
|
-
entries: Dirent<string>[],
|
|
51
|
-
result: ExpertSkillLayoutRepairResult
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const groups = new Map<string, { old: string[]; stage: string[] }>();
|
|
54
|
-
for (const entry of entries) {
|
|
55
|
-
const match = REPAIR_RESIDUE_RE.exec(entry.name);
|
|
56
|
-
if (!match) continue;
|
|
57
|
-
const group = groups.get(match[1]) ?? { old: [], stage: [] };
|
|
58
|
-
group[match[2] === "old" ? "old" : "stage"].push(path.join(skillsRoot, entry.name));
|
|
59
|
-
groups.set(match[1], group);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
for (const [code, group] of groups) {
|
|
63
|
-
const targetDir = path.join(skillsRoot, code);
|
|
64
|
-
try {
|
|
65
|
-
const targetStat = await lstatOrUndefined(targetDir);
|
|
66
|
-
if (targetStat?.isSymbolicLink() || (targetStat && !targetStat.isDirectory())) {
|
|
67
|
-
throw new Error(`规范路径不是可安全操作的真实目录: ${targetDir}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
group.old.sort().reverse();
|
|
71
|
-
if (!targetStat && group.old.length > 0) {
|
|
72
|
-
const restore = group.old.shift()!;
|
|
73
|
-
const restoreStat = await lstatOrUndefined(restore);
|
|
74
|
-
if (!restoreStat?.isDirectory() || restoreStat.isSymbolicLink()) {
|
|
75
|
-
throw new Error(`拒绝从非真实目录恢复: ${restore}`);
|
|
76
|
-
}
|
|
77
|
-
await fs.rename(restore, targetDir);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (await lstatOrUndefined(targetDir)) {
|
|
81
|
-
for (const residue of [...group.old, ...group.stage]) {
|
|
82
|
-
await removeRealDirectory(residue);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch (error: any) {
|
|
86
|
-
result.errors.push({ path: targetDir, message: error?.message || String(error) });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function promoteNestedSkill(sourceDir: string, targetDir: string): Promise<void> {
|
|
92
|
-
const parent = path.dirname(targetDir);
|
|
93
|
-
const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
94
|
-
const stage = path.join(parent, `.${path.basename(targetDir)}.repair-new-${tag}`);
|
|
95
|
-
const old = path.join(parent, `.${path.basename(targetDir)}.repair-old-${tag}`);
|
|
96
|
-
|
|
97
|
-
let movedOld = false;
|
|
98
|
-
try {
|
|
99
|
-
await fs.cp(sourceDir, stage, { recursive: true });
|
|
100
|
-
// 同名子项为目录时是历史嵌套,应删除;普通文件属于 Skill 内容,必须保留。
|
|
101
|
-
await removeNestedDirectoryIfPresent(path.join(stage, path.basename(targetDir)));
|
|
102
|
-
await fs.rename(targetDir, old);
|
|
103
|
-
movedOld = true;
|
|
104
|
-
await fs.rename(stage, targetDir);
|
|
105
|
-
} catch (error: any) {
|
|
106
|
-
const rollbackErrors: string[] = [];
|
|
107
|
-
try {
|
|
108
|
-
await removeRealDirectory(stage);
|
|
109
|
-
} catch (cleanupError: any) {
|
|
110
|
-
rollbackErrors.push(`清理暂存目录失败: ${cleanupError?.message || String(cleanupError)}`);
|
|
111
|
-
}
|
|
112
|
-
if (movedOld) {
|
|
113
|
-
try {
|
|
114
|
-
await fs.rename(old, targetDir);
|
|
115
|
-
} catch (restoreError: any) {
|
|
116
|
-
rollbackErrors.push(`恢复旧目录失败: ${restoreError?.message || String(restoreError)}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
if (rollbackErrors.length > 0) {
|
|
120
|
-
throw new Error(`${error?.message || String(error)}; ${rollbackErrors.join("; ")}`);
|
|
121
|
-
}
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
124
|
-
await removeRealDirectory(old);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* 修复历史 UPDATE_SKILL 将新版写入 `.user/skills/<code>/<code>` 的异常布局。
|
|
129
|
-
* 完整扫描同名目录链并提升全局最高版本;符号链接和版本不明的现场一律保守跳过。
|
|
130
|
-
*/
|
|
131
|
-
export async function repairNestedExpertSkillLayouts(
|
|
132
|
-
openclawRoot: string
|
|
133
|
-
): Promise<ExpertSkillLayoutRepairResult> {
|
|
134
|
-
const result: ExpertSkillLayoutRepairResult = {
|
|
135
|
-
scannedWorkspaces: 0,
|
|
136
|
-
repaired: [],
|
|
137
|
-
skipped: [],
|
|
138
|
-
errors: [],
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
let workspaceEntries;
|
|
142
|
-
try {
|
|
143
|
-
workspaceEntries = await fs.readdir(openclawRoot, { withFileTypes: true });
|
|
144
|
-
} catch (error: any) {
|
|
145
|
-
result.errors.push({ path: openclawRoot, message: error?.message || String(error) });
|
|
146
|
-
return result;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
for (const workspaceEntry of workspaceEntries) {
|
|
150
|
-
if (!workspaceEntry.isDirectory() || !ASSISTANT_WORKSPACE_RE.test(workspaceEntry.name)) continue;
|
|
151
|
-
result.scannedWorkspaces += 1;
|
|
152
|
-
const skillsRoot = path.join(openclawRoot, workspaceEntry.name, ".user", "skills");
|
|
153
|
-
let skillEntries;
|
|
154
|
-
try {
|
|
155
|
-
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
156
|
-
await recoverRepairResidues(skillsRoot, skillEntries, result);
|
|
157
|
-
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
158
|
-
} catch (error: any) {
|
|
159
|
-
if (error?.code !== "ENOENT") {
|
|
160
|
-
result.errors.push({ path: skillsRoot, message: error?.message || String(error) });
|
|
161
|
-
}
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const skillEntry of skillEntries) {
|
|
166
|
-
if (!skillEntry.isDirectory() || skillEntry.name.startsWith(".")) continue;
|
|
167
|
-
const outerDir = path.join(skillsRoot, skillEntry.name);
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
const candidates: Array<{ dir: string; version: string }> = [];
|
|
171
|
-
const outerHasSkill = await isRegularFile(path.join(outerDir, "SKILL.md"));
|
|
172
|
-
if (outerHasSkill) {
|
|
173
|
-
const version = await readSkillVersion(outerDir);
|
|
174
|
-
if (!version) {
|
|
175
|
-
result.skipped.push(outerDir);
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
candidates.push({ dir: outerDir, version });
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
let cursor = outerDir;
|
|
182
|
-
let nestedCount = 0;
|
|
183
|
-
while (true) {
|
|
184
|
-
const nestedDir = path.join(cursor, skillEntry.name);
|
|
185
|
-
const nestedStat = await lstatOrUndefined(nestedDir);
|
|
186
|
-
if (!nestedStat) break;
|
|
187
|
-
if (nestedStat.isSymbolicLink()) {
|
|
188
|
-
throw new Error(`拒绝跟随同名符号链接: ${nestedDir}`);
|
|
189
|
-
}
|
|
190
|
-
if (!nestedStat.isDirectory()) break;
|
|
191
|
-
cursor = nestedDir;
|
|
192
|
-
|
|
193
|
-
const skillMd = path.join(nestedDir, "SKILL.md");
|
|
194
|
-
const skillStat = await lstatOrUndefined(skillMd);
|
|
195
|
-
if (skillStat?.isSymbolicLink()) {
|
|
196
|
-
throw new Error(`拒绝读取符号链接 SKILL.md: ${skillMd}`);
|
|
197
|
-
}
|
|
198
|
-
if (!skillStat?.isFile()) continue;
|
|
199
|
-
nestedCount += 1;
|
|
200
|
-
const version = await readSkillVersion(nestedDir);
|
|
201
|
-
if (!version) {
|
|
202
|
-
result.skipped.push(outerDir);
|
|
203
|
-
candidates.length = 0;
|
|
204
|
-
break;
|
|
205
|
-
}
|
|
206
|
-
candidates.push({ dir: nestedDir, version });
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (nestedCount === 0 || candidates.length === 0) continue;
|
|
210
|
-
if (candidates.some((candidate) => !isComparableVersion(candidate.version))) {
|
|
211
|
-
result.skipped.push(outerDir);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
let source = candidates[0];
|
|
215
|
-
for (const candidate of candidates.slice(1)) {
|
|
216
|
-
// 同版本也选择更深层,确保把异常同名子目录收敛掉。
|
|
217
|
-
if (compareVersions(candidate.version, source.version) >= 0) source = candidate;
|
|
218
|
-
}
|
|
219
|
-
if (source.dir === outerDir) {
|
|
220
|
-
result.skipped.push(outerDir);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await promoteNestedSkill(source.dir, outerDir);
|
|
225
|
-
result.repaired.push(outerDir);
|
|
226
|
-
} catch (error: any) {
|
|
227
|
-
result.errors.push({ path: outerDir, message: error?.message || String(error) });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return result;
|
|
233
|
-
}
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import { compareVersions, isComparableVersion } from "./semver.ts";
|
|
6
|
+
import { readSkillVersion } from "./skill-version.ts";
|
|
7
|
+
|
|
8
|
+
const ASSISTANT_WORKSPACE_RE = /^workspace-assistant-\d{5,}$/;
|
|
9
|
+
const REPAIR_RESIDUE_RE = /^\.(.+)\.repair-(old|new)-(.+)$/;
|
|
10
|
+
|
|
11
|
+
export type ExpertSkillLayoutRepairResult = {
|
|
12
|
+
scannedWorkspaces: number;
|
|
13
|
+
repaired: string[];
|
|
14
|
+
skipped: string[];
|
|
15
|
+
errors: Array<{ path: string; message: string }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function lstatOrUndefined(filePath: string) {
|
|
19
|
+
try {
|
|
20
|
+
return await fs.lstat(filePath);
|
|
21
|
+
} catch (error: any) {
|
|
22
|
+
if (error?.code === "ENOENT") return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function isRegularFile(filePath: string): Promise<boolean> {
|
|
28
|
+
const stat = await lstatOrUndefined(filePath);
|
|
29
|
+
return Boolean(stat?.isFile() && !stat.isSymbolicLink());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function removeRealDirectory(dirPath: string): Promise<void> {
|
|
33
|
+
const stat = await lstatOrUndefined(dirPath);
|
|
34
|
+
if (!stat) return;
|
|
35
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
36
|
+
throw new Error(`拒绝清理非真实目录: ${dirPath}`);
|
|
37
|
+
}
|
|
38
|
+
await fs.rm(dirPath, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function removeNestedDirectoryIfPresent(dirPath: string): Promise<void> {
|
|
42
|
+
const stat = await lstatOrUndefined(dirPath);
|
|
43
|
+
if (!stat) return;
|
|
44
|
+
if (stat.isSymbolicLink()) throw new Error(`拒绝清理符号链接: ${dirPath}`);
|
|
45
|
+
if (stat.isDirectory()) await fs.rm(dirPath, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function recoverRepairResidues(
|
|
49
|
+
skillsRoot: string,
|
|
50
|
+
entries: Dirent<string>[],
|
|
51
|
+
result: ExpertSkillLayoutRepairResult
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const groups = new Map<string, { old: string[]; stage: string[] }>();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const match = REPAIR_RESIDUE_RE.exec(entry.name);
|
|
56
|
+
if (!match) continue;
|
|
57
|
+
const group = groups.get(match[1]) ?? { old: [], stage: [] };
|
|
58
|
+
group[match[2] === "old" ? "old" : "stage"].push(path.join(skillsRoot, entry.name));
|
|
59
|
+
groups.set(match[1], group);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [code, group] of groups) {
|
|
63
|
+
const targetDir = path.join(skillsRoot, code);
|
|
64
|
+
try {
|
|
65
|
+
const targetStat = await lstatOrUndefined(targetDir);
|
|
66
|
+
if (targetStat?.isSymbolicLink() || (targetStat && !targetStat.isDirectory())) {
|
|
67
|
+
throw new Error(`规范路径不是可安全操作的真实目录: ${targetDir}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
group.old.sort().reverse();
|
|
71
|
+
if (!targetStat && group.old.length > 0) {
|
|
72
|
+
const restore = group.old.shift()!;
|
|
73
|
+
const restoreStat = await lstatOrUndefined(restore);
|
|
74
|
+
if (!restoreStat?.isDirectory() || restoreStat.isSymbolicLink()) {
|
|
75
|
+
throw new Error(`拒绝从非真实目录恢复: ${restore}`);
|
|
76
|
+
}
|
|
77
|
+
await fs.rename(restore, targetDir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (await lstatOrUndefined(targetDir)) {
|
|
81
|
+
for (const residue of [...group.old, ...group.stage]) {
|
|
82
|
+
await removeRealDirectory(residue);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (error: any) {
|
|
86
|
+
result.errors.push({ path: targetDir, message: error?.message || String(error) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function promoteNestedSkill(sourceDir: string, targetDir: string): Promise<void> {
|
|
92
|
+
const parent = path.dirname(targetDir);
|
|
93
|
+
const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
94
|
+
const stage = path.join(parent, `.${path.basename(targetDir)}.repair-new-${tag}`);
|
|
95
|
+
const old = path.join(parent, `.${path.basename(targetDir)}.repair-old-${tag}`);
|
|
96
|
+
|
|
97
|
+
let movedOld = false;
|
|
98
|
+
try {
|
|
99
|
+
await fs.cp(sourceDir, stage, { recursive: true });
|
|
100
|
+
// 同名子项为目录时是历史嵌套,应删除;普通文件属于 Skill 内容,必须保留。
|
|
101
|
+
await removeNestedDirectoryIfPresent(path.join(stage, path.basename(targetDir)));
|
|
102
|
+
await fs.rename(targetDir, old);
|
|
103
|
+
movedOld = true;
|
|
104
|
+
await fs.rename(stage, targetDir);
|
|
105
|
+
} catch (error: any) {
|
|
106
|
+
const rollbackErrors: string[] = [];
|
|
107
|
+
try {
|
|
108
|
+
await removeRealDirectory(stage);
|
|
109
|
+
} catch (cleanupError: any) {
|
|
110
|
+
rollbackErrors.push(`清理暂存目录失败: ${cleanupError?.message || String(cleanupError)}`);
|
|
111
|
+
}
|
|
112
|
+
if (movedOld) {
|
|
113
|
+
try {
|
|
114
|
+
await fs.rename(old, targetDir);
|
|
115
|
+
} catch (restoreError: any) {
|
|
116
|
+
rollbackErrors.push(`恢复旧目录失败: ${restoreError?.message || String(restoreError)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (rollbackErrors.length > 0) {
|
|
120
|
+
throw new Error(`${error?.message || String(error)}; ${rollbackErrors.join("; ")}`);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
await removeRealDirectory(old);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 修复历史 UPDATE_SKILL 将新版写入 `.user/skills/<code>/<code>` 的异常布局。
|
|
129
|
+
* 完整扫描同名目录链并提升全局最高版本;符号链接和版本不明的现场一律保守跳过。
|
|
130
|
+
*/
|
|
131
|
+
export async function repairNestedExpertSkillLayouts(
|
|
132
|
+
openclawRoot: string
|
|
133
|
+
): Promise<ExpertSkillLayoutRepairResult> {
|
|
134
|
+
const result: ExpertSkillLayoutRepairResult = {
|
|
135
|
+
scannedWorkspaces: 0,
|
|
136
|
+
repaired: [],
|
|
137
|
+
skipped: [],
|
|
138
|
+
errors: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let workspaceEntries;
|
|
142
|
+
try {
|
|
143
|
+
workspaceEntries = await fs.readdir(openclawRoot, { withFileTypes: true });
|
|
144
|
+
} catch (error: any) {
|
|
145
|
+
result.errors.push({ path: openclawRoot, message: error?.message || String(error) });
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const workspaceEntry of workspaceEntries) {
|
|
150
|
+
if (!workspaceEntry.isDirectory() || !ASSISTANT_WORKSPACE_RE.test(workspaceEntry.name)) continue;
|
|
151
|
+
result.scannedWorkspaces += 1;
|
|
152
|
+
const skillsRoot = path.join(openclawRoot, workspaceEntry.name, ".user", "skills");
|
|
153
|
+
let skillEntries;
|
|
154
|
+
try {
|
|
155
|
+
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
156
|
+
await recoverRepairResidues(skillsRoot, skillEntries, result);
|
|
157
|
+
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
158
|
+
} catch (error: any) {
|
|
159
|
+
if (error?.code !== "ENOENT") {
|
|
160
|
+
result.errors.push({ path: skillsRoot, message: error?.message || String(error) });
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const skillEntry of skillEntries) {
|
|
166
|
+
if (!skillEntry.isDirectory() || skillEntry.name.startsWith(".")) continue;
|
|
167
|
+
const outerDir = path.join(skillsRoot, skillEntry.name);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const candidates: Array<{ dir: string; version: string }> = [];
|
|
171
|
+
const outerHasSkill = await isRegularFile(path.join(outerDir, "SKILL.md"));
|
|
172
|
+
if (outerHasSkill) {
|
|
173
|
+
const version = await readSkillVersion(outerDir);
|
|
174
|
+
if (!version) {
|
|
175
|
+
result.skipped.push(outerDir);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
candidates.push({ dir: outerDir, version });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let cursor = outerDir;
|
|
182
|
+
let nestedCount = 0;
|
|
183
|
+
while (true) {
|
|
184
|
+
const nestedDir = path.join(cursor, skillEntry.name);
|
|
185
|
+
const nestedStat = await lstatOrUndefined(nestedDir);
|
|
186
|
+
if (!nestedStat) break;
|
|
187
|
+
if (nestedStat.isSymbolicLink()) {
|
|
188
|
+
throw new Error(`拒绝跟随同名符号链接: ${nestedDir}`);
|
|
189
|
+
}
|
|
190
|
+
if (!nestedStat.isDirectory()) break;
|
|
191
|
+
cursor = nestedDir;
|
|
192
|
+
|
|
193
|
+
const skillMd = path.join(nestedDir, "SKILL.md");
|
|
194
|
+
const skillStat = await lstatOrUndefined(skillMd);
|
|
195
|
+
if (skillStat?.isSymbolicLink()) {
|
|
196
|
+
throw new Error(`拒绝读取符号链接 SKILL.md: ${skillMd}`);
|
|
197
|
+
}
|
|
198
|
+
if (!skillStat?.isFile()) continue;
|
|
199
|
+
nestedCount += 1;
|
|
200
|
+
const version = await readSkillVersion(nestedDir);
|
|
201
|
+
if (!version) {
|
|
202
|
+
result.skipped.push(outerDir);
|
|
203
|
+
candidates.length = 0;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
candidates.push({ dir: nestedDir, version });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (nestedCount === 0 || candidates.length === 0) continue;
|
|
210
|
+
if (candidates.some((candidate) => !isComparableVersion(candidate.version))) {
|
|
211
|
+
result.skipped.push(outerDir);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
let source = candidates[0];
|
|
215
|
+
for (const candidate of candidates.slice(1)) {
|
|
216
|
+
// 同版本也选择更深层,确保把异常同名子目录收敛掉。
|
|
217
|
+
if (compareVersions(candidate.version, source.version) >= 0) source = candidate;
|
|
218
|
+
}
|
|
219
|
+
if (source.dir === outerDir) {
|
|
220
|
+
result.skipped.push(outerDir);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await promoteNestedSkill(source.dir, outerDir);
|
|
225
|
+
result.repaired.push(outerDir);
|
|
226
|
+
} catch (error: any) {
|
|
227
|
+
result.errors.push({ path: outerDir, message: error?.message || String(error) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
}
|