dsh-plugin-tool-management 0.1.0
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/LICENSE +21 -0
- package/README.md +136 -0
- package/README_EN.md +136 -0
- package/cordis.patch.yml +31 -0
- package/lib/client.js +850 -0
- package/lib/index.js +1930 -0
- package/lib/skills/core.js +2612 -0
- package/lib/skills/readonly-discovery.js +153 -0
- package/lib/skills/service.js +417 -0
- package/package.json +71 -0
|
@@ -0,0 +1,2612 @@
|
|
|
1
|
+
// dsh-plugin-tool-management core —— 纯 Node 技能文件管理核心(仅 ZIP 解压使用 fflate,可独立单测)
|
|
2
|
+
// 移植自 dsh-skills-manager 的 core(Apache-2.0);来源裁剪为 DSH / Agents / Codex / Claude。
|
|
3
|
+
//
|
|
4
|
+
// 覆盖 DSH 用户级与活动 Session 项目级技能根:
|
|
5
|
+
// - 用户根目录:~/.dsh/skills、~/.agents/skills、~/.codex/skills、~/.claude/skills
|
|
6
|
+
// - 项目根目录:<project>/.dsh/skills(可启停、创建、回收)、<project>/.agents/skills(本地策略启停、源文件只读)
|
|
7
|
+
// - 条目形态:<root>/<name>/SKILL.md(bundle)或 <root>/<name>.md(flat),只读来源递归发现,可写来源只扫一层
|
|
8
|
+
// - 前端展示 name、description 与启停状态,不做格式检查或自动修复
|
|
9
|
+
//
|
|
10
|
+
// 所有函数返回普通结果对象,业务校验失败返回 { ok: false, error, code?, params? };
|
|
11
|
+
// error 保持中文原文(兼容性红线),code 为点分小写业务错误码,params 供前端词典占位符替换;
|
|
12
|
+
// 系统异常(fs ENOENT 等)透传 String(e.message),不加 code。
|
|
13
|
+
// 文件写入错误由路由返回给调用方。
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, basename, dirname, resolve, relative, isAbsolute, sep, } from "node:path";
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
18
|
+
import { unzipSync } from "fflate";
|
|
19
|
+
import { discoverReadonlyEntries, validDiscoveryName, } from "./readonly-discovery.js";
|
|
20
|
+
const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
const PROJECT_ROOT_KEY_RE = /^project-(?:dsh|agents):[a-f0-9]{16}$/;
|
|
22
|
+
const CUSTOM_ROOT_KEY_RE = /^custom-[a-f0-9]{16}$/;
|
|
23
|
+
const USER_DSH_POLICY_RANK = 399;
|
|
24
|
+
const WINDOWS_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
25
|
+
const MAX_SOURCE_DEPTH = 64;
|
|
26
|
+
const MAX_ENTRY_NAME_LENGTH = 128;
|
|
27
|
+
const MAX_BROWSE_ENTRIES = 500;
|
|
28
|
+
const MAX_UPLOAD_ARCHIVE_BYTES = 32 << 20;
|
|
29
|
+
const MAX_UPLOAD_ENTRY_BYTES = 32 << 20;
|
|
30
|
+
const MAX_UPLOAD_TOTAL_BYTES = 64 << 20;
|
|
31
|
+
const MAX_UPLOAD_ENTRIES = 1000;
|
|
32
|
+
const MAX_UPLOAD_PATH_LENGTH = 512;
|
|
33
|
+
const TRANSIENT_RENAME_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
34
|
+
// ── 业务错误码 ────────────────────────────────────────────────────────────────
|
|
35
|
+
/** 构造带 code/params 的业务 Error,供导入链路 throw 后透传到失败明细。 */
|
|
36
|
+
function codedError(message, code, params) {
|
|
37
|
+
const error = new Error(message);
|
|
38
|
+
error.code = code;
|
|
39
|
+
error.params = params;
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
/** 把业务 Error 的 code/params 附加到失败明细;系统异常(ENOENT 等,非 error.* 前缀)保持原文。 */
|
|
43
|
+
function attachCode(item, error) {
|
|
44
|
+
if (error && typeof error.code === "string" && /^error\./.test(error.code))
|
|
45
|
+
item.code = error.code;
|
|
46
|
+
if (error && error.params)
|
|
47
|
+
item.params = error.params;
|
|
48
|
+
return item;
|
|
49
|
+
}
|
|
50
|
+
// ── 路径解析 ────────────────────────────────────────────────────────────────
|
|
51
|
+
export function resolveDshHome() {
|
|
52
|
+
return process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
53
|
+
}
|
|
54
|
+
export function resolveAgentsHome() {
|
|
55
|
+
return process.env.DSH_AGENTS_HOME || join(homedir(), ".agents");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* DSH 与外部 Agent 的用户级技能目录(4 个来源;外部来源由 manager provider 接入)。
|
|
59
|
+
*
|
|
60
|
+
* rank 与官方 filesystem provider 的用户级优先级衔接:DSH=400、Agents=500。
|
|
61
|
+
* manager provider 以 450 接管公共 Agents(仍低于 DSH),Codex/Claude 依次排在其后。
|
|
62
|
+
*/
|
|
63
|
+
export function userRoots() {
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
key: "dsh",
|
|
67
|
+
path: join(resolveDshHome(), "skills"),
|
|
68
|
+
label: "DSH 技能",
|
|
69
|
+
mutable: true,
|
|
70
|
+
toggleable: true,
|
|
71
|
+
native: true,
|
|
72
|
+
rank: 400,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "agents",
|
|
76
|
+
path: join(resolveAgentsHome(), "skills"),
|
|
77
|
+
label: "公共 Agent",
|
|
78
|
+
mutable: false,
|
|
79
|
+
toggleable: true,
|
|
80
|
+
native: true,
|
|
81
|
+
rank: 450,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "codex",
|
|
85
|
+
path: join(process.env.DSH_CODEX_HOME || join(homedir(), ".codex"), "skills"),
|
|
86
|
+
label: "Codex",
|
|
87
|
+
mutable: false,
|
|
88
|
+
toggleable: true,
|
|
89
|
+
native: false,
|
|
90
|
+
rank: 520,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: "claude",
|
|
94
|
+
path: join(process.env.DSH_CLAUDE_HOME || join(homedir(), ".claude"), "skills"),
|
|
95
|
+
label: "Claude",
|
|
96
|
+
mutable: false,
|
|
97
|
+
toggleable: true,
|
|
98
|
+
native: false,
|
|
99
|
+
rank: 530,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
function projectIdentity(path) {
|
|
104
|
+
const canonical = pathIdentity(path);
|
|
105
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
// ── 自定义技能目录(用户在管理页添加的任意只读来源)─────────────────────────
|
|
108
|
+
//
|
|
109
|
+
// 存储在 manager 状态文件的 customRoots 数组:{ key, path, label? }。
|
|
110
|
+
// key 由路径哈希派生(custom-<sha256-16>),因此 key 恒等于路径的身份;
|
|
111
|
+
// 目录以只读来源接入(bundle / flat / 递归发现,与 agents/codex/claude 同一
|
|
112
|
+
// 通道),启停走 sources 全局开关,停用某个技能走 disabledSkills 策略,
|
|
113
|
+
// 均不改源文件。
|
|
114
|
+
/** 由目录路径派生稳定的自定义来源 key。 */
|
|
115
|
+
export function customRootKey(path) {
|
|
116
|
+
return "custom-" + projectIdentity(path);
|
|
117
|
+
}
|
|
118
|
+
/** 从状态值解析自定义来源定义;非法条目静默丢弃,key 与 path 不匹配的不信任。 */
|
|
119
|
+
export function customRootsFromState(stateValue) {
|
|
120
|
+
const list = stateValue && Array.isArray(stateValue.customRoots)
|
|
121
|
+
? stateValue.customRoots
|
|
122
|
+
: [];
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const item of list) {
|
|
125
|
+
if (!item || typeof item !== "object")
|
|
126
|
+
continue;
|
|
127
|
+
const path = typeof item.path === "string" ? item.path : "";
|
|
128
|
+
const key = typeof item.key === "string" ? item.key : "";
|
|
129
|
+
if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
|
|
130
|
+
continue;
|
|
131
|
+
if (key !== customRootKey(path))
|
|
132
|
+
continue;
|
|
133
|
+
out.push({
|
|
134
|
+
key,
|
|
135
|
+
path,
|
|
136
|
+
label: typeof item.label === "string" && item.label.trim()
|
|
137
|
+
? item.label.trim().slice(0, 64)
|
|
138
|
+
: "自定义目录",
|
|
139
|
+
mutable: false,
|
|
140
|
+
toggleable: true,
|
|
141
|
+
native: false,
|
|
142
|
+
scope: "user",
|
|
143
|
+
rank: 600,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 添加自定义技能目录:要求绝对路径、真实存在的目录、与任何已知来源
|
|
150
|
+
* (用户根 + 现有自定义根)不重叠——重叠来源会绕过停用策略。
|
|
151
|
+
*/
|
|
152
|
+
export async function addCustomRoot(inputPath, label, log) {
|
|
153
|
+
const requested = String(inputPath == null ? "" : inputPath).trim();
|
|
154
|
+
if (!requested || !isAbsolute(requested))
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `目录路径必须是绝对路径: ${requested}`,
|
|
158
|
+
code: "error.custom.absolute",
|
|
159
|
+
params: { path: requested },
|
|
160
|
+
};
|
|
161
|
+
let canonical;
|
|
162
|
+
try {
|
|
163
|
+
canonical = await fs.realpath(requested);
|
|
164
|
+
const st = await fs.stat(canonical);
|
|
165
|
+
if (!st.isDirectory())
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: `不是目录: ${canonical}`,
|
|
169
|
+
code: "error.custom.notDirectory",
|
|
170
|
+
params: { path: canonical },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: `无法读取目录: ${requested}`,
|
|
177
|
+
code: "error.custom.unreadable",
|
|
178
|
+
params: { path: requested },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const current = await readManagerState();
|
|
182
|
+
if (current.writable === false)
|
|
183
|
+
return invalidManagerStateWrite();
|
|
184
|
+
const existing = customRootsFromState(current.state);
|
|
185
|
+
for (const known of userRoots()
|
|
186
|
+
.map((root) => root.path)
|
|
187
|
+
.concat(existing.map((root) => root.path))) {
|
|
188
|
+
if (await pathsOverlap(canonical, known))
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `目录与已有技能来源重叠: ${canonical} ↔ ${known}`,
|
|
192
|
+
code: "error.custom.overlap",
|
|
193
|
+
params: { path: canonical, other: known },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const key = customRootKey(canonical);
|
|
197
|
+
const cleanLabel = String(label == null ? "" : label).trim().slice(0, 64);
|
|
198
|
+
const entry = { key, path: canonical };
|
|
199
|
+
if (cleanLabel)
|
|
200
|
+
entry.label = cleanLabel;
|
|
201
|
+
current.state.customRoots = [...existing.map((root) => ({ key: root.key, path: root.path, ...(root.label !== "自定义目录" ? { label: root.label } : {}) })), entry];
|
|
202
|
+
// 新来源默认启用;用户可随后用来源开关停用。
|
|
203
|
+
current.state.sources[key] = true;
|
|
204
|
+
await writeManagerState(current.state);
|
|
205
|
+
if (log)
|
|
206
|
+
log("custom-add", `添加自定义技能目录 ${canonical} (${key})`);
|
|
207
|
+
return { key, path: canonical, label: cleanLabel || "自定义目录" };
|
|
208
|
+
}
|
|
209
|
+
/** 移除自定义来源及其全部策略键(技能源文件不受影响)。 */
|
|
210
|
+
export async function removeCustomRoot(key, log) {
|
|
211
|
+
const clean = String(key == null ? "" : key).trim();
|
|
212
|
+
if (!CUSTOM_ROOT_KEY_RE.test(clean))
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
error: `非法的自定义来源 key: ${clean}`,
|
|
216
|
+
code: "error.custom.invalidKey",
|
|
217
|
+
params: { key: clean },
|
|
218
|
+
};
|
|
219
|
+
const current = await readManagerState();
|
|
220
|
+
if (current.writable === false)
|
|
221
|
+
return invalidManagerStateWrite();
|
|
222
|
+
const remaining = customRootsFromState(current.state);
|
|
223
|
+
const target = remaining.find((root) => root.key === clean);
|
|
224
|
+
if (!target)
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: `自定义来源不存在: ${clean}`,
|
|
228
|
+
code: "error.custom.notFound",
|
|
229
|
+
params: { key: clean },
|
|
230
|
+
};
|
|
231
|
+
current.state.customRoots = remaining
|
|
232
|
+
.filter((root) => root.key !== clean)
|
|
233
|
+
.map((root) => ({
|
|
234
|
+
key: root.key,
|
|
235
|
+
path: root.path,
|
|
236
|
+
...(root.label !== "自定义目录" ? { label: root.label } : {}),
|
|
237
|
+
}));
|
|
238
|
+
delete current.state.sources[clean];
|
|
239
|
+
delete current.state.disabledSkills[clean];
|
|
240
|
+
delete current.state.enabledSkills[clean];
|
|
241
|
+
await writeManagerState(current.state);
|
|
242
|
+
if (log)
|
|
243
|
+
log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
|
|
244
|
+
return { key: clean, path: target.path };
|
|
245
|
+
}
|
|
246
|
+
async function nearestProjectRoot(cwd) {
|
|
247
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd))
|
|
248
|
+
return null;
|
|
249
|
+
let start;
|
|
250
|
+
try {
|
|
251
|
+
start = await fs.realpath(resolve(cwd));
|
|
252
|
+
if (!(await fs.stat(start)).isDirectory())
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return { unavailable: true, cwd };
|
|
257
|
+
}
|
|
258
|
+
let current = start;
|
|
259
|
+
for (;;) {
|
|
260
|
+
try {
|
|
261
|
+
await fs.lstat(join(current, ".git"));
|
|
262
|
+
return { root: current, cwd: start };
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
/* 继续向上查找最近的项目根 */
|
|
266
|
+
}
|
|
267
|
+
const parent = dirname(current);
|
|
268
|
+
if (parent === current)
|
|
269
|
+
return null;
|
|
270
|
+
current = parent;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function projectSourceSafe(definition) {
|
|
274
|
+
// 只读来源允许目录链接,但重叠来源会绕过用户根的停用策略。
|
|
275
|
+
if (definition.kind === "project-agents")
|
|
276
|
+
return !(await overlapsUserSkillRoot(definition.path));
|
|
277
|
+
const container = join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents");
|
|
278
|
+
for (const path of [container, definition.path]) {
|
|
279
|
+
const st = await lstatOrNull(path);
|
|
280
|
+
if (st && (!st.isDirectory() || st.isSymbolicLink()))
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return !(await overlapsUserSkillRoot(definition.path));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
|
|
287
|
+
* 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
|
|
288
|
+
*/
|
|
289
|
+
export async function projectRoots(projectCwds = [], diagnostics) {
|
|
290
|
+
const projects = new Map();
|
|
291
|
+
for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
|
|
292
|
+
const found = await nearestProjectRoot(cwd);
|
|
293
|
+
if (!found || !found.root) {
|
|
294
|
+
if (found &&
|
|
295
|
+
found.unavailable &&
|
|
296
|
+
Array.isArray(diagnostics) &&
|
|
297
|
+
typeof cwd === "string" &&
|
|
298
|
+
isAbsolute(cwd)) {
|
|
299
|
+
diagnostics.push({
|
|
300
|
+
code: "warning.project.unavailable",
|
|
301
|
+
params: { path: cwd },
|
|
302
|
+
error: `无法从宿主读取活动工作区,项目技能未显示: ${cwd}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const identity = pathIdentity(found.root);
|
|
308
|
+
const existing = projects.get(identity) || {
|
|
309
|
+
root: found.root,
|
|
310
|
+
cwds: new Set(),
|
|
311
|
+
};
|
|
312
|
+
existing.cwds.add(found.cwd);
|
|
313
|
+
projects.set(identity, existing);
|
|
314
|
+
}
|
|
315
|
+
const roots = [];
|
|
316
|
+
for (const project of [...projects.values()].sort((a, b) => a.root.localeCompare(b.root))) {
|
|
317
|
+
const id = projectIdentity(project.root);
|
|
318
|
+
const common = {
|
|
319
|
+
mutable: false,
|
|
320
|
+
toggleable: false,
|
|
321
|
+
native: true,
|
|
322
|
+
scope: "project",
|
|
323
|
+
projectRoot: project.root,
|
|
324
|
+
projectName: basename(project.root) || project.root,
|
|
325
|
+
workspaceCwds: [...project.cwds].sort(),
|
|
326
|
+
};
|
|
327
|
+
const candidates = [
|
|
328
|
+
{
|
|
329
|
+
...common,
|
|
330
|
+
key: `project-dsh:${id}`,
|
|
331
|
+
kind: "project-dsh",
|
|
332
|
+
localeKey: "projectDsh",
|
|
333
|
+
path: join(project.root, ".dsh", "skills"),
|
|
334
|
+
label: "Project DSH",
|
|
335
|
+
rank: 100,
|
|
336
|
+
mutable: true,
|
|
337
|
+
toggleable: true,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
...common,
|
|
341
|
+
key: `project-agents:${id}`,
|
|
342
|
+
kind: "project-agents",
|
|
343
|
+
localeKey: "projectAgents",
|
|
344
|
+
path: join(project.root, ".agents", "skills"),
|
|
345
|
+
label: "Project Agent",
|
|
346
|
+
rank: 200,
|
|
347
|
+
toggleable: true,
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
for (const candidate of candidates) {
|
|
351
|
+
if (await projectSourceSafe(candidate))
|
|
352
|
+
roots.push(candidate);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return roots;
|
|
356
|
+
}
|
|
357
|
+
export function managerHomePath() {
|
|
358
|
+
return join(resolveDshHome(), "dsh-plugin-tool-management");
|
|
359
|
+
}
|
|
360
|
+
export function managerStatePath() {
|
|
361
|
+
return join(managerHomePath(), "state.json");
|
|
362
|
+
}
|
|
363
|
+
export function trashRootPath() {
|
|
364
|
+
return join(managerHomePath(), "trash");
|
|
365
|
+
}
|
|
366
|
+
export function logPath() {
|
|
367
|
+
return join(resolveDshHome(), "dsh-plugin-tool-management.log");
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 为前端内嵌目录选择器列出一个本机目录层级。
|
|
371
|
+
* 不跟随目录符号链接;选择后的导入仍由 importSkill 做完整安全校验。
|
|
372
|
+
*/
|
|
373
|
+
export async function browseDirectories(inputPath) {
|
|
374
|
+
const requested = String(inputPath == null ? "" : inputPath).trim();
|
|
375
|
+
const target = requested === "" ? homedir() : requested;
|
|
376
|
+
if (!isAbsolute(target)) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
error: `目录路径必须是绝对路径: ${target}`,
|
|
380
|
+
code: "error.browse.absolute",
|
|
381
|
+
params: { path: target },
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
let canonical;
|
|
385
|
+
let directory;
|
|
386
|
+
try {
|
|
387
|
+
canonical = await fs.realpath(target);
|
|
388
|
+
directory = await fs.stat(canonical);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
error: `无法读取目录: ${target}`,
|
|
394
|
+
code: "error.browse.unreadable",
|
|
395
|
+
params: {
|
|
396
|
+
path: target,
|
|
397
|
+
error: String(error && error.message ? error.message : error),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (!directory.isDirectory()) {
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
error: `不是目录: ${target}`,
|
|
405
|
+
code: "error.browse.notDirectory",
|
|
406
|
+
params: { path: target },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const entries = [];
|
|
410
|
+
let truncated = false;
|
|
411
|
+
try {
|
|
412
|
+
const items = await fs.readdir(canonical, { withFileTypes: true });
|
|
413
|
+
for (const item of items) {
|
|
414
|
+
// 目录链接不在浏览器中展开,避免选择器在不知情时跨越到另一棵目录树。
|
|
415
|
+
if (!item.isDirectory() || item.isSymbolicLink())
|
|
416
|
+
continue;
|
|
417
|
+
// Dirent 来自一次目录快照;再用 lstat 校验当前条目,既收紧 TOCTOU 窗口,
|
|
418
|
+
// 也明确排除 Windows junction 等重解析目录。
|
|
419
|
+
const childPath = join(canonical, item.name);
|
|
420
|
+
const childStat = await lstatOrNull(childPath);
|
|
421
|
+
if (!childStat || !childStat.isDirectory() || childStat.isSymbolicLink())
|
|
422
|
+
continue;
|
|
423
|
+
if (entries.length >= MAX_BROWSE_ENTRIES) {
|
|
424
|
+
truncated = true;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
entries.push({
|
|
428
|
+
name: item.name,
|
|
429
|
+
path: childPath,
|
|
430
|
+
hidden: item.name.startsWith("."),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
error: `无法读取目录: ${canonical}`,
|
|
438
|
+
code: "error.browse.unreadable",
|
|
439
|
+
params: {
|
|
440
|
+
path: canonical,
|
|
441
|
+
error: String(error && error.message ? error.message : error),
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, {
|
|
446
|
+
sensitivity: "base",
|
|
447
|
+
numeric: true,
|
|
448
|
+
}));
|
|
449
|
+
const crumbs = [];
|
|
450
|
+
let cursor = canonical;
|
|
451
|
+
for (;;) {
|
|
452
|
+
const parent = dirname(cursor);
|
|
453
|
+
crumbs.unshift({
|
|
454
|
+
name: parent === cursor ? cursor : basename(cursor),
|
|
455
|
+
path: cursor,
|
|
456
|
+
hidden: false,
|
|
457
|
+
});
|
|
458
|
+
if (parent === cursor)
|
|
459
|
+
break;
|
|
460
|
+
cursor = parent;
|
|
461
|
+
}
|
|
462
|
+
return { path: canonical, home: homedir(), crumbs, entries, truncated };
|
|
463
|
+
}
|
|
464
|
+
function dshRootPath() {
|
|
465
|
+
return userRoots().find((root) => root.key === "dsh").path;
|
|
466
|
+
}
|
|
467
|
+
/** 只读来源的拒绝结果;action 为可翻译语义值(toggle/delete)。 */
|
|
468
|
+
function readonlyError(action) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
code: "error.root.readonly",
|
|
472
|
+
params: { action },
|
|
473
|
+
error: action === "delete"
|
|
474
|
+
? "该技能来源不允许删除"
|
|
475
|
+
: "该技能来源不允许启用或停用",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function rootDefinition(root) {
|
|
479
|
+
if (root && typeof root === "object" && typeof root.key === "string")
|
|
480
|
+
return root;
|
|
481
|
+
if (typeof root !== "string")
|
|
482
|
+
return null;
|
|
483
|
+
const resolved = resolve(root);
|
|
484
|
+
return userRoots().find((item) => resolve(item.path) === resolved) || null;
|
|
485
|
+
}
|
|
486
|
+
function rootByKey(key) {
|
|
487
|
+
return userRoots().find((item) => item.key === key) || null;
|
|
488
|
+
}
|
|
489
|
+
/** 只允许用户 DSH 根,或由活动 Session 推导出的项目 DSH 根参与文件写入。 */
|
|
490
|
+
function writableRootDefinition(root) {
|
|
491
|
+
const definition = rootDefinition(root);
|
|
492
|
+
if (!definition || definition.mutable !== true)
|
|
493
|
+
return null;
|
|
494
|
+
if (definition.key === "dsh")
|
|
495
|
+
return resolve(definition.path) === resolve(dshRootPath())
|
|
496
|
+
? rootByKey("dsh")
|
|
497
|
+
: null;
|
|
498
|
+
if (definition.scope !== "project" ||
|
|
499
|
+
definition.kind !== "project-dsh" ||
|
|
500
|
+
typeof definition.projectRoot !== "string" ||
|
|
501
|
+
!isAbsolute(definition.projectRoot) ||
|
|
502
|
+
definition.key !==
|
|
503
|
+
`project-dsh:${projectIdentity(definition.projectRoot)}` ||
|
|
504
|
+
resolve(definition.path) !==
|
|
505
|
+
resolve(join(definition.projectRoot, ".dsh", "skills")))
|
|
506
|
+
return null;
|
|
507
|
+
return definition;
|
|
508
|
+
}
|
|
509
|
+
async function checkedWritableRootDefinition(root) {
|
|
510
|
+
const definition = writableRootDefinition(root);
|
|
511
|
+
if (!definition || definition.scope !== "project")
|
|
512
|
+
return definition;
|
|
513
|
+
if (await overlapsUserSkillRoot(definition.path)) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
error: `项目技能目录与用户技能目录重叠,拒绝写入: ${definition.path}`,
|
|
517
|
+
code: "error.root.unsafe",
|
|
518
|
+
params: { path: definition.path },
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
|
|
522
|
+
for (const path of [join(definition.projectRoot, ".dsh"), definition.path]) {
|
|
523
|
+
const st = await lstatOrNull(path);
|
|
524
|
+
if (st && (!st.isDirectory() || st.isSymbolicLink())) {
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
error: `项目技能目录不安全,拒绝写入: ${path}`,
|
|
528
|
+
code: "error.root.unsafe",
|
|
529
|
+
params: { path },
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return definition;
|
|
534
|
+
}
|
|
535
|
+
/** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
|
|
536
|
+
function isSameOrDescendant(parent, child) {
|
|
537
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
538
|
+
return (rel === "" ||
|
|
539
|
+
(!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)));
|
|
540
|
+
}
|
|
541
|
+
/** 解析真实路径;中间若有目录链接,按落地目录比较重叠。 */
|
|
542
|
+
async function resolvedPath(path) {
|
|
543
|
+
try {
|
|
544
|
+
return await fs.realpath(path);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return resolve(path);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** 即使末级路径尚不存在,也解析最近既存祖先中的链接,供权限域重叠判断。 */
|
|
551
|
+
async function comparisonPath(path) {
|
|
552
|
+
let current = resolve(path);
|
|
553
|
+
const missing = [];
|
|
554
|
+
for (;;) {
|
|
555
|
+
try {
|
|
556
|
+
return resolve(await fs.realpath(current), ...missing);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
const parent = dirname(current);
|
|
560
|
+
if (parent === current)
|
|
561
|
+
return resolve(path);
|
|
562
|
+
missing.unshift(basename(current));
|
|
563
|
+
current = parent;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function overlapsUserSkillRoot(path) {
|
|
568
|
+
const candidate = await comparisonPath(path);
|
|
569
|
+
for (const root of userRoots()) {
|
|
570
|
+
const userPath = await comparisonPath(root.path);
|
|
571
|
+
if (isSameOrDescendant(candidate, userPath) ||
|
|
572
|
+
isSameOrDescendant(userPath, candidate))
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
/** 两个路径重叠时,覆盖导入可能删除自身来源,必须拒绝。 */
|
|
578
|
+
async function pathsOverlap(a, b) {
|
|
579
|
+
const left = await resolvedPath(a);
|
|
580
|
+
const right = await resolvedPath(b);
|
|
581
|
+
return isSameOrDescendant(left, right) || isSameOrDescendant(right, left);
|
|
582
|
+
}
|
|
583
|
+
/** 预解析技能根后的根内校验,供逐条目扫描复用同一次 realpath,减少重复 IO。 */
|
|
584
|
+
async function isInsideResolvedRoot(rootReal, path) {
|
|
585
|
+
return isSameOrDescendant(rootReal, await resolvedPath(path));
|
|
586
|
+
}
|
|
587
|
+
function pathIdentity(path) {
|
|
588
|
+
const canonical = resolve(path);
|
|
589
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
590
|
+
}
|
|
591
|
+
async function lstatOrNull(path) {
|
|
592
|
+
try {
|
|
593
|
+
return await fs.lstat(path);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
|
|
600
|
+
export function entryPath(root, name) {
|
|
601
|
+
if (typeof name !== "string" ||
|
|
602
|
+
name === "" ||
|
|
603
|
+
name === "." ||
|
|
604
|
+
name === ".." ||
|
|
605
|
+
name.startsWith(".") ||
|
|
606
|
+
name.length > MAX_ENTRY_NAME_LENGTH ||
|
|
607
|
+
/[\\/:*?"<>|\0]/.test(name) ||
|
|
608
|
+
/[. ]$/.test(name) ||
|
|
609
|
+
WINDOWS_DEVICE_NAME_RE.test(name) ||
|
|
610
|
+
basename(name) !== name)
|
|
611
|
+
return null;
|
|
612
|
+
const rootPath = resolve(root);
|
|
613
|
+
const path = resolve(rootPath, name);
|
|
614
|
+
return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
|
|
615
|
+
}
|
|
616
|
+
function isDshRoot(root) {
|
|
617
|
+
return typeof root === "string" && resolve(root) === resolve(dshRootPath());
|
|
618
|
+
}
|
|
619
|
+
// ── 命名规整 ────────────────────────────────────────────────────────────────
|
|
620
|
+
/** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
|
|
621
|
+
export function toKebab(s) {
|
|
622
|
+
let t = String(s).trim();
|
|
623
|
+
if (t === "")
|
|
624
|
+
return "";
|
|
625
|
+
t = t.replace(/([a-z0-9])([A-Z])/g, "$1-$2"); // camelCase 边界
|
|
626
|
+
t = t.toLowerCase();
|
|
627
|
+
t = t.replace(/[\s_.]+/g, "-");
|
|
628
|
+
t = t.replace(/[^a-z0-9-]/g, "-");
|
|
629
|
+
t = t.replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
630
|
+
return t;
|
|
631
|
+
}
|
|
632
|
+
// ── frontmatter 解析 / 序列化(宽松 YAML 对象,保留键序)────────────────────
|
|
633
|
+
/** 剥离 UTF-8 BOM(Windows 工具常写入,不剥离会导致开头 --- 失配)。 */
|
|
634
|
+
function stripBom(text) {
|
|
635
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
636
|
+
}
|
|
637
|
+
/** 解析 SKILL.md 的 frontmatter。返回 { fields, map, body },map 保留键序。
|
|
638
|
+
* 只识别顶层 key: value;缩进嵌套字段(如 metadata.source)不进入 map。 */
|
|
639
|
+
export function parseSkillDoc(text) {
|
|
640
|
+
const src = stripBom(String(text));
|
|
641
|
+
const lines = src.split(/\r?\n/);
|
|
642
|
+
const map = Object.create(null);
|
|
643
|
+
const fields = [];
|
|
644
|
+
let body = src;
|
|
645
|
+
let hasFrontmatter = false;
|
|
646
|
+
if (lines.length > 0 && lines[0].trim() === "---") {
|
|
647
|
+
let end = -1;
|
|
648
|
+
for (let i = 1; i < lines.length; i++) {
|
|
649
|
+
if (lines[i].trim() === "---") {
|
|
650
|
+
end = i;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (end >= 0) {
|
|
655
|
+
hasFrontmatter = true;
|
|
656
|
+
for (let i = 1; i < end; i++) {
|
|
657
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[i]);
|
|
658
|
+
if (m) {
|
|
659
|
+
fields.push({ key: m[1], raw: m[2] });
|
|
660
|
+
const block = /^([>|])[-+]?\s*$/.exec(m[2]);
|
|
661
|
+
if (block) {
|
|
662
|
+
const content = [];
|
|
663
|
+
while (i + 1 < end &&
|
|
664
|
+
(/^\s/.test(lines[i + 1]) || lines[i + 1] === "")) {
|
|
665
|
+
i++;
|
|
666
|
+
content.push(lines[i]);
|
|
667
|
+
}
|
|
668
|
+
const indentation = content
|
|
669
|
+
.filter((line) => line.trim() !== "")
|
|
670
|
+
.reduce((min, line) => Math.min(min, (/^\s*/.exec(line) || [""])[0].length), Infinity);
|
|
671
|
+
const normalized = content.map((line) => Number.isFinite(indentation)
|
|
672
|
+
? line.slice(Math.min(indentation, line.length))
|
|
673
|
+
: line);
|
|
674
|
+
map[m[1]] =
|
|
675
|
+
block[1] === ">"
|
|
676
|
+
? normalized.join(" ").replace(/\s+/g, " ").trim()
|
|
677
|
+
: normalized.join("\n").trim();
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
map[m[1]] = decodeYamlScalar(m[2]);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
body = lines.slice(end + 1).join("\n");
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { fields, map, body, hasFrontmatter };
|
|
688
|
+
}
|
|
689
|
+
/** 读取 YAML 标量的显示值;不依赖第三方 YAML 解析器。 */
|
|
690
|
+
function decodeYamlScalar(v) {
|
|
691
|
+
const s = String(v == null ? "" : v).trim();
|
|
692
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
693
|
+
try {
|
|
694
|
+
const value = JSON.parse(s);
|
|
695
|
+
if (typeof value === "string")
|
|
696
|
+
return value;
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
/* 保留无法解析的原始内容 */
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'")
|
|
703
|
+
return s.slice(1, -1).replace(/''/g, "'");
|
|
704
|
+
return s;
|
|
705
|
+
}
|
|
706
|
+
export function unquote(v) {
|
|
707
|
+
const s = String(v == null ? "" : v).trim();
|
|
708
|
+
if (s.length >= 2 &&
|
|
709
|
+
((s[0] === '"' && s[s.length - 1] === '"') ||
|
|
710
|
+
(s[0] === "'" && s[s.length - 1] === "'"))) {
|
|
711
|
+
return s.slice(1, -1);
|
|
712
|
+
}
|
|
713
|
+
return s;
|
|
714
|
+
}
|
|
715
|
+
/** 同目录临时文件加 rename,避免写入中断时截断原 SKILL.md。 */
|
|
716
|
+
/** Windows 上杀毒软件或索引器可能短暂占用目录;只重试明确可恢复的 rename 错误。 */
|
|
717
|
+
export async function renameWithRetry(source, destination, options = {}) {
|
|
718
|
+
const rename = typeof options.rename === "function" ? options.rename : fs.rename;
|
|
719
|
+
const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0
|
|
720
|
+
? options.maxAttempts
|
|
721
|
+
: 6;
|
|
722
|
+
const delayMs = Number.isFinite(options.delayMs) && options.delayMs >= 0
|
|
723
|
+
? options.delayMs
|
|
724
|
+
: 40;
|
|
725
|
+
for (let attempt = 1;; attempt++) {
|
|
726
|
+
try {
|
|
727
|
+
return await rename(source, destination);
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
if (!TRANSIENT_RENAME_CODES.has(error && error.code) ||
|
|
731
|
+
attempt >= maxAttempts)
|
|
732
|
+
throw error;
|
|
733
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs * attempt));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async function removeMovedPath(path) {
|
|
738
|
+
const st = await lstatOrNull(path);
|
|
739
|
+
if (!st)
|
|
740
|
+
return;
|
|
741
|
+
if (st.isDirectory() && !st.isSymbolicLink())
|
|
742
|
+
await fs.rm(path, { recursive: true, force: false });
|
|
743
|
+
else
|
|
744
|
+
await fs.unlink(path);
|
|
745
|
+
}
|
|
746
|
+
/** rename 跨盘返回 EXDEV 时:先完整复制,再在源盘原子隐藏源条目,最后清理隐藏副本。 */
|
|
747
|
+
async function movePathWithFallback(source, destination, options = {}) {
|
|
748
|
+
try {
|
|
749
|
+
await renameWithRetry(source, destination, options);
|
|
750
|
+
return { copied: false, cleanupError: null };
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (!error || error.code !== "EXDEV")
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
const quarantine = join(dirname(source), `.${basename(source)}.dssm-move-${randomUUID()}`);
|
|
757
|
+
try {
|
|
758
|
+
await fs.cp(source, destination, {
|
|
759
|
+
recursive: true,
|
|
760
|
+
dereference: false,
|
|
761
|
+
errorOnExist: true,
|
|
762
|
+
force: false,
|
|
763
|
+
verbatimSymlinks: true,
|
|
764
|
+
});
|
|
765
|
+
// 源与 quarantine 位于同一目录;成功后原技能名立即消失,避免递归删除留下半份可见条目。
|
|
766
|
+
await renameWithRetry(source, quarantine, options);
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
await removeMovedPath(destination).catch(() => undefined);
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
let cleanupError = null;
|
|
773
|
+
try {
|
|
774
|
+
await removeMovedPath(quarantine);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
cleanupError = error;
|
|
778
|
+
}
|
|
779
|
+
return { copied: true, cleanupError, quarantine };
|
|
780
|
+
}
|
|
781
|
+
async function writeFileAtomically(path, content) {
|
|
782
|
+
const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
|
|
783
|
+
try {
|
|
784
|
+
await fs.writeFile(temp, content, "utf8");
|
|
785
|
+
await fs.rename(temp, path);
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
await fs.rm(temp, { force: true }).catch(() => undefined);
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** 解析布尔字段值;合法布尔返回 true/false,非法返回 undefined。 */
|
|
793
|
+
export function parseBoolValue(raw) {
|
|
794
|
+
const v = unquote(raw).trim().toLowerCase();
|
|
795
|
+
if (v === "true" || v === "yes" || v === "on" || v === "1")
|
|
796
|
+
return true;
|
|
797
|
+
if (v === "false" || v === "no" || v === "off" || v === "0")
|
|
798
|
+
return false;
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
// ── 条目定位 / 扫描 ─────────────────────────────────────────────────────────
|
|
802
|
+
/** 按名称解析条目(bundle 优先,其次 flat)。找不到返回 null。 */
|
|
803
|
+
export async function resolveEntry(root, name) {
|
|
804
|
+
const definition = rootDefinition(root);
|
|
805
|
+
if (definition && !definition.mutable) {
|
|
806
|
+
if (!validDiscoveryName(name))
|
|
807
|
+
return null;
|
|
808
|
+
return ((await discoverReadonlyEntries(definition.path)).entries.find((entry) => entry.name === name) || null);
|
|
809
|
+
}
|
|
810
|
+
root = definition ? definition.path : root;
|
|
811
|
+
try {
|
|
812
|
+
const bundlePath = entryPath(root, name);
|
|
813
|
+
if (bundlePath === null)
|
|
814
|
+
return null;
|
|
815
|
+
const rootPath = resolve(root);
|
|
816
|
+
const rootStat = await lstatOrNull(rootPath);
|
|
817
|
+
if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
818
|
+
return null;
|
|
819
|
+
const rootReal = await resolvedPath(rootPath);
|
|
820
|
+
const bundleStat = await lstatOrNull(bundlePath);
|
|
821
|
+
if (bundleStat &&
|
|
822
|
+
bundleStat.isDirectory() &&
|
|
823
|
+
!bundleStat.isSymbolicLink()) {
|
|
824
|
+
const bundleDoc = join(bundlePath, "SKILL.md");
|
|
825
|
+
const docStat = await lstatOrNull(bundleDoc);
|
|
826
|
+
if (docStat &&
|
|
827
|
+
docStat.isFile() &&
|
|
828
|
+
!docStat.isSymbolicLink() &&
|
|
829
|
+
(await isInsideResolvedRoot(rootReal, bundleDoc))) {
|
|
830
|
+
return {
|
|
831
|
+
kind: "bundle",
|
|
832
|
+
docPath: bundleDoc,
|
|
833
|
+
entryPath: bundlePath,
|
|
834
|
+
realDocPath: await fs.realpath(bundleDoc),
|
|
835
|
+
realEntryPath: await fs.realpath(bundlePath),
|
|
836
|
+
linked: false,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const flatDoc = resolve(rootPath, `${name}.md`);
|
|
841
|
+
if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc)
|
|
842
|
+
return null;
|
|
843
|
+
const flatStat = await lstatOrNull(flatDoc);
|
|
844
|
+
if (flatStat &&
|
|
845
|
+
flatStat.isFile() &&
|
|
846
|
+
!flatStat.isSymbolicLink() &&
|
|
847
|
+
(await isInsideResolvedRoot(rootReal, flatDoc))) {
|
|
848
|
+
const realDocPath = await fs.realpath(flatDoc);
|
|
849
|
+
return {
|
|
850
|
+
kind: "flat",
|
|
851
|
+
docPath: flatDoc,
|
|
852
|
+
entryPath: flatDoc,
|
|
853
|
+
realDocPath,
|
|
854
|
+
realEntryPath: realDocPath,
|
|
855
|
+
linked: false,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// lstat 与 realpath/readFile 之间允许来源变化,调用方统一按不存在处理。
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function entryOf(name, kind, docPath, doc) {
|
|
866
|
+
const declaredName = doc.map.name !== undefined ? unquote(doc.map.name) : "";
|
|
867
|
+
const description = doc.map.description !== undefined ? unquote(doc.map.description) : "";
|
|
868
|
+
const modelValue = parseBoolValue(doc.map["disable-model-invocation"]);
|
|
869
|
+
const userValue = parseBoolValue(doc.map["user-invocable"]);
|
|
870
|
+
const modelDisabled = modelValue === true;
|
|
871
|
+
const userDisabled = userValue === false;
|
|
872
|
+
const invocationPolicyValid = (doc.map["disable-model-invocation"] === undefined ||
|
|
873
|
+
modelValue !== undefined) &&
|
|
874
|
+
(doc.map["user-invocable"] === undefined || userValue !== undefined);
|
|
875
|
+
const diagnostics = [];
|
|
876
|
+
if (!doc.hasFrontmatter)
|
|
877
|
+
diagnostics.push({
|
|
878
|
+
level: "error",
|
|
879
|
+
code: "diagnostic.frontmatter.missing",
|
|
880
|
+
});
|
|
881
|
+
if (doc.hasFrontmatter && !declaredName)
|
|
882
|
+
diagnostics.push({ level: "error", code: "diagnostic.name.missing" });
|
|
883
|
+
else if (declaredName && !KEBAB_RE.test(declaredName))
|
|
884
|
+
diagnostics.push({
|
|
885
|
+
level: "error",
|
|
886
|
+
code: "diagnostic.name.invalid",
|
|
887
|
+
params: { name: declaredName },
|
|
888
|
+
});
|
|
889
|
+
if (doc.hasFrontmatter && !description)
|
|
890
|
+
diagnostics.push({
|
|
891
|
+
level: "error",
|
|
892
|
+
code: "diagnostic.description.missing",
|
|
893
|
+
});
|
|
894
|
+
if (!invocationPolicyValid)
|
|
895
|
+
diagnostics.push({ level: "error", code: "diagnostic.invocation.invalid" });
|
|
896
|
+
return {
|
|
897
|
+
name,
|
|
898
|
+
declaredName,
|
|
899
|
+
kind,
|
|
900
|
+
docPath,
|
|
901
|
+
description,
|
|
902
|
+
modelInvocable: !modelDisabled,
|
|
903
|
+
userInvocable: !userDisabled,
|
|
904
|
+
invocationPolicyValid,
|
|
905
|
+
hasFrontmatter: doc.hasFrontmatter,
|
|
906
|
+
// 调用策略值异常可以由 manager 本地策略覆盖;结构本身合法即可加载。
|
|
907
|
+
loadable: doc.hasFrontmatter && KEBAB_RE.test(declaredName) && description !== "",
|
|
908
|
+
diagnostics,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/** 只读来源递归发现;可写 DSH 根维持顶层扫描及链接写边界。 */
|
|
912
|
+
export async function scanEntries(root, options = {}) {
|
|
913
|
+
const definition = rootDefinition(root);
|
|
914
|
+
if (definition && !definition.mutable) {
|
|
915
|
+
const discovered = await discoverReadonlyEntries(definition.path);
|
|
916
|
+
if (options.metadataOnly)
|
|
917
|
+
return discovered;
|
|
918
|
+
const entries = [];
|
|
919
|
+
for (const entry of discovered.entries) {
|
|
920
|
+
try {
|
|
921
|
+
entries.push({
|
|
922
|
+
...entryOf(entry.name, entry.kind, entry.docPath, parseSkillDoc(await fs.readFile(entry.realDocPath, "utf8"))),
|
|
923
|
+
...entry,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
/* 忽略已失效或不可读技能。 */
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return { ...discovered, entries };
|
|
931
|
+
}
|
|
932
|
+
root = definition ? definition.path : root;
|
|
933
|
+
const rootStat = await lstatOrNull(resolve(root));
|
|
934
|
+
if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
935
|
+
return { exists: false, entries: [] };
|
|
936
|
+
let items;
|
|
937
|
+
try {
|
|
938
|
+
items = await fs.readdir(root, { withFileTypes: true });
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
return { exists: false, entries: [] };
|
|
942
|
+
}
|
|
943
|
+
const byName = new Map();
|
|
944
|
+
const rootReal = await resolvedPath(root);
|
|
945
|
+
for (const it of items) {
|
|
946
|
+
try {
|
|
947
|
+
if (it.isSymbolicLink())
|
|
948
|
+
continue;
|
|
949
|
+
if (it.isDirectory() && entryPath(root, it.name) !== null) {
|
|
950
|
+
const docPath = join(root, it.name, "SKILL.md");
|
|
951
|
+
const st = await lstatOrNull(docPath);
|
|
952
|
+
if (!st ||
|
|
953
|
+
!st.isFile() ||
|
|
954
|
+
st.isSymbolicLink() ||
|
|
955
|
+
!(await isInsideResolvedRoot(rootReal, docPath)))
|
|
956
|
+
continue;
|
|
957
|
+
const doc = options.metadataOnly
|
|
958
|
+
? { map: {}, hasFrontmatter: false }
|
|
959
|
+
: parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
960
|
+
byName.set(it.name, {
|
|
961
|
+
...entryOf(it.name, "bundle", docPath, doc),
|
|
962
|
+
entryPath: join(root, it.name),
|
|
963
|
+
realDocPath: await fs.realpath(docPath),
|
|
964
|
+
realEntryPath: await fs.realpath(join(root, it.name)),
|
|
965
|
+
linked: false,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
else if (it.isFile() &&
|
|
969
|
+
it.name.toLowerCase().endsWith(".md") &&
|
|
970
|
+
it.name.toLowerCase() !== "skill.md" &&
|
|
971
|
+
entryPath(root, it.name.slice(0, -3)) !== null) {
|
|
972
|
+
const skillName = it.name.slice(0, -3);
|
|
973
|
+
if (byName.has(skillName))
|
|
974
|
+
continue;
|
|
975
|
+
const docPath = join(root, it.name);
|
|
976
|
+
if (!(await isInsideResolvedRoot(rootReal, docPath)))
|
|
977
|
+
continue;
|
|
978
|
+
const doc = options.metadataOnly
|
|
979
|
+
? { map: {}, hasFrontmatter: false }
|
|
980
|
+
: parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
981
|
+
const realDocPath = await fs.realpath(docPath);
|
|
982
|
+
byName.set(skillName, {
|
|
983
|
+
...entryOf(skillName, "flat", docPath, doc),
|
|
984
|
+
entryPath: docPath,
|
|
985
|
+
realDocPath,
|
|
986
|
+
realEntryPath: realDocPath,
|
|
987
|
+
linked: false,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
/* 跳过不可读条目 */
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
996
|
+
return { exists: true, entries };
|
|
997
|
+
}
|
|
998
|
+
/** 同一真实技能优先归属直接 SSOT;同类条目再按来源 rank 决胜。 */
|
|
999
|
+
async function scanDeduplicatedRoots(roots = userRoots(), options = {}) {
|
|
1000
|
+
const results = await Promise.all(roots.map(async (root) => [root, await scanEntries(root, options)]));
|
|
1001
|
+
const scans = new Map();
|
|
1002
|
+
const groups = new Map();
|
|
1003
|
+
for (const [root, scan] of results) {
|
|
1004
|
+
scans.set(root.key, scan);
|
|
1005
|
+
for (const entry of scan.entries) {
|
|
1006
|
+
const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
|
|
1007
|
+
const group = groups.get(identity) || [];
|
|
1008
|
+
group.push({ root, entry });
|
|
1009
|
+
groups.set(identity, group);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const winners = new Set();
|
|
1013
|
+
for (const group of groups.values()) {
|
|
1014
|
+
group.sort((left, right) => Number(left.entry.linked) - Number(right.entry.linked) ||
|
|
1015
|
+
left.root.rank - right.root.rank);
|
|
1016
|
+
const winner = group[0];
|
|
1017
|
+
winner.entry.providerRank = Math.min(...group.map((item) => item.root.rank));
|
|
1018
|
+
winner.entry.policyAliases = group.map((item) => ({
|
|
1019
|
+
rootKey: item.root.key,
|
|
1020
|
+
name: item.entry.name,
|
|
1021
|
+
}));
|
|
1022
|
+
winners.add(winner.entry);
|
|
1023
|
+
}
|
|
1024
|
+
for (const scan of scans.values()) {
|
|
1025
|
+
scan.entries = scan.entries.filter((entry) => winners.has(entry));
|
|
1026
|
+
}
|
|
1027
|
+
return scans;
|
|
1028
|
+
}
|
|
1029
|
+
/** 详情与列表共享路径去重,扫描定位信息时不读取无关技能正文。 */
|
|
1030
|
+
async function visibleEntryForRoot(root, name) {
|
|
1031
|
+
if (!validDiscoveryName(name))
|
|
1032
|
+
return null;
|
|
1033
|
+
// custom 来源只扫自身(不在 userRoots 里,否则根本找不到条目)。
|
|
1034
|
+
const roots = root.scope === "project" || CUSTOM_ROOT_KEY_RE.test(root.key || "")
|
|
1035
|
+
? [root]
|
|
1036
|
+
: userRoots();
|
|
1037
|
+
const scans = await scanDeduplicatedRoots(roots, { metadataOnly: true });
|
|
1038
|
+
return (scans.get(root.key)?.entries.find((entry) => entry.name === name) || null);
|
|
1039
|
+
}
|
|
1040
|
+
// ── Manager 本地策略(外部源只读,启停状态写入 DSH_HOME)────────────────────
|
|
1041
|
+
function defaultManagerState() {
|
|
1042
|
+
const sources = Object.create(null);
|
|
1043
|
+
const disabledSkills = Object.create(null);
|
|
1044
|
+
const enabledSkills = Object.create(null);
|
|
1045
|
+
for (const root of userRoots()) {
|
|
1046
|
+
if (root.key !== "dsh")
|
|
1047
|
+
sources[root.key] = true;
|
|
1048
|
+
disabledSkills[root.key] = [];
|
|
1049
|
+
enabledSkills[root.key] = [];
|
|
1050
|
+
}
|
|
1051
|
+
return { version: 1, sources, disabledSkills, enabledSkills, customRoots: [] };
|
|
1052
|
+
}
|
|
1053
|
+
function validStateSkillName(name) {
|
|
1054
|
+
return validDiscoveryName(name);
|
|
1055
|
+
}
|
|
1056
|
+
/** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
|
|
1057
|
+
function failClosedManagerState() {
|
|
1058
|
+
const state = defaultManagerState();
|
|
1059
|
+
for (const key of Object.keys(state.sources))
|
|
1060
|
+
state.sources[key] = false;
|
|
1061
|
+
return state;
|
|
1062
|
+
}
|
|
1063
|
+
function validPolicyLists(value, allowMissing = false) {
|
|
1064
|
+
if (value === undefined && allowMissing)
|
|
1065
|
+
return true;
|
|
1066
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1067
|
+
return false;
|
|
1068
|
+
for (const [key, list] of Object.entries(value)) {
|
|
1069
|
+
if (!userRoots().some((root) => root.key === key) &&
|
|
1070
|
+
!PROJECT_ROOT_KEY_RE.test(key))
|
|
1071
|
+
continue;
|
|
1072
|
+
if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
function validManagerStateDocument(value) {
|
|
1078
|
+
if (!value ||
|
|
1079
|
+
typeof value !== "object" ||
|
|
1080
|
+
Array.isArray(value) ||
|
|
1081
|
+
value.version !== 1)
|
|
1082
|
+
return false;
|
|
1083
|
+
if (!value.sources ||
|
|
1084
|
+
typeof value.sources !== "object" ||
|
|
1085
|
+
Array.isArray(value.sources))
|
|
1086
|
+
return false;
|
|
1087
|
+
if (!validPolicyLists(value.disabledSkills) ||
|
|
1088
|
+
!validPolicyLists(value.enabledSkills, true))
|
|
1089
|
+
return false;
|
|
1090
|
+
// 自定义来源列表:可选字段;容器必须是数组(条目级合法性由 normalize 逐条裁剪)。
|
|
1091
|
+
if (value.customRoots !== undefined && !Array.isArray(value.customRoots))
|
|
1092
|
+
return false;
|
|
1093
|
+
for (const root of userRoots()) {
|
|
1094
|
+
if (root.key === "dsh")
|
|
1095
|
+
continue;
|
|
1096
|
+
if (typeof value.sources[root.key] !== "boolean")
|
|
1097
|
+
return false;
|
|
1098
|
+
const list = value.disabledSkills[root.key];
|
|
1099
|
+
if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
const enabledSkills = value.enabledSkills || {};
|
|
1103
|
+
for (const key of new Set([
|
|
1104
|
+
...Object.keys(value.disabledSkills),
|
|
1105
|
+
...Object.keys(enabledSkills),
|
|
1106
|
+
])) {
|
|
1107
|
+
const disabled = new Set(value.disabledSkills[key] || []);
|
|
1108
|
+
if ((enabledSkills[key] || []).some((name) => disabled.has(name)))
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
function normalizeManagerState(value) {
|
|
1114
|
+
const normalized = defaultManagerState();
|
|
1115
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1116
|
+
return normalized;
|
|
1117
|
+
// 自定义来源先归一,其 sources/policy 键才有归属。
|
|
1118
|
+
normalized.customRoots = normalizeCustomRoots(value.customRoots);
|
|
1119
|
+
const customKeys = new Set(normalized.customRoots.map((root) => root.key));
|
|
1120
|
+
for (const root of userRoots()) {
|
|
1121
|
+
if (root.key !== "dsh" &&
|
|
1122
|
+
value.sources &&
|
|
1123
|
+
typeof value.sources[root.key] === "boolean")
|
|
1124
|
+
normalized.sources[root.key] = value.sources[root.key];
|
|
1125
|
+
const list = value.disabledSkills && value.disabledSkills[root.key];
|
|
1126
|
+
if (Array.isArray(list))
|
|
1127
|
+
normalized.disabledSkills[root.key] = [
|
|
1128
|
+
...new Set(list.filter((name) => validStateSkillName(name))),
|
|
1129
|
+
].sort();
|
|
1130
|
+
const enabled = value.enabledSkills && value.enabledSkills[root.key];
|
|
1131
|
+
if (Array.isArray(enabled))
|
|
1132
|
+
normalized.enabledSkills[root.key] = [
|
|
1133
|
+
...new Set(enabled.filter((name) => validStateSkillName(name))),
|
|
1134
|
+
].sort();
|
|
1135
|
+
}
|
|
1136
|
+
if (value.sources && typeof value.sources === "object" && !Array.isArray(value.sources)) {
|
|
1137
|
+
for (const [key, flag] of Object.entries(value.sources)) {
|
|
1138
|
+
if (customKeys.has(key) && typeof flag === "boolean")
|
|
1139
|
+
normalized.sources[key] = flag;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
for (const field of ["disabledSkills", "enabledSkills"]) {
|
|
1143
|
+
const source = value[field];
|
|
1144
|
+
if (source && typeof source === "object" && !Array.isArray(source)) {
|
|
1145
|
+
for (const [key, list] of Object.entries(source)) {
|
|
1146
|
+
if (!PROJECT_ROOT_KEY_RE.test(key) && !customKeys.has(key))
|
|
1147
|
+
continue;
|
|
1148
|
+
if (!Array.isArray(list))
|
|
1149
|
+
continue;
|
|
1150
|
+
normalized[field][key] = [
|
|
1151
|
+
...new Set(list.filter(validStateSkillName)),
|
|
1152
|
+
].sort();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return normalized;
|
|
1157
|
+
}
|
|
1158
|
+
/** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
|
|
1159
|
+
function normalizeCustomRoots(value) {
|
|
1160
|
+
if (!Array.isArray(value))
|
|
1161
|
+
return [];
|
|
1162
|
+
const out = [];
|
|
1163
|
+
const seen = new Set();
|
|
1164
|
+
for (const item of value) {
|
|
1165
|
+
if (!item || typeof item !== "object")
|
|
1166
|
+
continue;
|
|
1167
|
+
const path = typeof item.path === "string" ? item.path : "";
|
|
1168
|
+
const key = typeof item.key === "string" ? item.key : "";
|
|
1169
|
+
if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
|
|
1170
|
+
continue;
|
|
1171
|
+
if (key !== customRootKey(path) || seen.has(key))
|
|
1172
|
+
continue;
|
|
1173
|
+
seen.add(key);
|
|
1174
|
+
out.push(typeof item.label === "string" && item.label.trim()
|
|
1175
|
+
? { key, path, label: item.label.trim().slice(0, 64) }
|
|
1176
|
+
: { key, path });
|
|
1177
|
+
}
|
|
1178
|
+
return out;
|
|
1179
|
+
}
|
|
1180
|
+
export async function readManagerState() {
|
|
1181
|
+
try {
|
|
1182
|
+
const raw = await fs.readFile(managerStatePath(), "utf8");
|
|
1183
|
+
const parsed = JSON.parse(raw);
|
|
1184
|
+
if (!validManagerStateDocument(parsed))
|
|
1185
|
+
throw codedError("invalid manager state schema", "error.state.invalid");
|
|
1186
|
+
return {
|
|
1187
|
+
state: normalizeManagerState(parsed),
|
|
1188
|
+
warning: null,
|
|
1189
|
+
writable: true,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
if (error && error.code === "ENOENT")
|
|
1194
|
+
return { state: defaultManagerState(), warning: null, writable: true };
|
|
1195
|
+
return {
|
|
1196
|
+
state: failClosedManagerState(),
|
|
1197
|
+
writable: false,
|
|
1198
|
+
warning: {
|
|
1199
|
+
code: "warning.state.invalid",
|
|
1200
|
+
params: { path: managerStatePath() },
|
|
1201
|
+
error: `技能管理器状态文件不可读,所有技能已安全停用且状态写入已锁定: ${managerStatePath()}`,
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function writeManagerState(value) {
|
|
1207
|
+
await fs.mkdir(managerHomePath(), { recursive: true });
|
|
1208
|
+
await writeFileAtomically(managerStatePath(), `${JSON.stringify(normalizeManagerState(value), null, 2)}\n`);
|
|
1209
|
+
}
|
|
1210
|
+
function managerSkillOverride(policy, rootKey, name, policyAliases = []) {
|
|
1211
|
+
if ((policy.enabledSkills[rootKey] || []).includes(name))
|
|
1212
|
+
return true;
|
|
1213
|
+
if ((policy.disabledSkills[rootKey] || []).includes(name))
|
|
1214
|
+
return false;
|
|
1215
|
+
let inheritedEnable = false;
|
|
1216
|
+
for (const alias of policyAliases) {
|
|
1217
|
+
if (alias.rootKey === rootKey && alias.name === name)
|
|
1218
|
+
continue;
|
|
1219
|
+
if ((policy.disabledSkills[alias.rootKey] || []).includes(alias.name))
|
|
1220
|
+
return false;
|
|
1221
|
+
if ((policy.enabledSkills[alias.rootKey] || []).includes(alias.name))
|
|
1222
|
+
inheritedEnable = true;
|
|
1223
|
+
}
|
|
1224
|
+
if (inheritedEnable)
|
|
1225
|
+
return true;
|
|
1226
|
+
return undefined;
|
|
1227
|
+
}
|
|
1228
|
+
function effectiveSkillPolicy(policyResult, root, entry) {
|
|
1229
|
+
const override = managerSkillOverride(policyResult.state, root.key, entry.name, entry.policyAliases);
|
|
1230
|
+
const sourceEnabled = root.key === "dsh" ||
|
|
1231
|
+
root.scope === "project" ||
|
|
1232
|
+
policyResult.state.sources[root.key] !== false;
|
|
1233
|
+
if (policyResult.writable === false || !sourceEnabled || override === false) {
|
|
1234
|
+
return {
|
|
1235
|
+
override,
|
|
1236
|
+
sourceEnabled,
|
|
1237
|
+
modelInvocable: false,
|
|
1238
|
+
userInvocable: false,
|
|
1239
|
+
enabled: false,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
if (override === true) {
|
|
1243
|
+
return {
|
|
1244
|
+
override,
|
|
1245
|
+
sourceEnabled,
|
|
1246
|
+
modelInvocable: true,
|
|
1247
|
+
userInvocable: true,
|
|
1248
|
+
enabled: true,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
const modelInvocable = entry.invocationPolicyValid && entry.modelInvocable;
|
|
1252
|
+
const userInvocable = entry.invocationPolicyValid && entry.userInvocable;
|
|
1253
|
+
return {
|
|
1254
|
+
override,
|
|
1255
|
+
sourceEnabled,
|
|
1256
|
+
modelInvocable,
|
|
1257
|
+
userInvocable,
|
|
1258
|
+
enabled: modelInvocable && userInvocable,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function invalidManagerStateWrite() {
|
|
1262
|
+
return {
|
|
1263
|
+
ok: false,
|
|
1264
|
+
code: "error.state.invalid",
|
|
1265
|
+
params: { path: managerStatePath() },
|
|
1266
|
+
error: `技能管理器状态文件不可读,已拒绝覆盖: ${managerStatePath()}`,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
export async function setSourceEnabled(rootOrKey, enabled, log) {
|
|
1270
|
+
// 支持传 key(静态来源)或 definition 对象(自定义来源由 service 层从状态文件生成)。
|
|
1271
|
+
const root = rootOrKey && typeof rootOrKey === "object" && typeof rootOrKey.key === "string"
|
|
1272
|
+
? rootOrKey
|
|
1273
|
+
: rootByKey(rootOrKey);
|
|
1274
|
+
if (!root || root.key === "dsh" || !root.toggleable)
|
|
1275
|
+
return readonlyError("toggle");
|
|
1276
|
+
const current = await readManagerState();
|
|
1277
|
+
if (current.writable === false)
|
|
1278
|
+
return invalidManagerStateWrite();
|
|
1279
|
+
current.state.sources[root.key] = enabled === true;
|
|
1280
|
+
await writeManagerState(current.state);
|
|
1281
|
+
if (log)
|
|
1282
|
+
log(enabled ? "source-enable" : "source-disable", `${enabled ? "启用" : "停用"}来源 ${root.key}: ${root.path}`);
|
|
1283
|
+
return { root: root.key, enabled: enabled === true };
|
|
1284
|
+
}
|
|
1285
|
+
async function checkedPolicyRootDefinition(root) {
|
|
1286
|
+
const definition = rootDefinition(root);
|
|
1287
|
+
if (!definition || !definition.toggleable)
|
|
1288
|
+
return null;
|
|
1289
|
+
if (definition.scope !== "project") {
|
|
1290
|
+
// 自定义来源的 definition 由 service 层从状态文件生成,key 已与路径绑定。
|
|
1291
|
+
if (CUSTOM_ROOT_KEY_RE.test(definition.key || ""))
|
|
1292
|
+
return definition;
|
|
1293
|
+
const canonical = rootByKey(definition.key);
|
|
1294
|
+
return canonical && resolve(canonical.path) === resolve(definition.path)
|
|
1295
|
+
? canonical
|
|
1296
|
+
: null;
|
|
1297
|
+
}
|
|
1298
|
+
const validProjectRoot = (definition.kind === "project-dsh" ||
|
|
1299
|
+
definition.kind === "project-agents") &&
|
|
1300
|
+
PROJECT_ROOT_KEY_RE.test(definition.key) &&
|
|
1301
|
+
typeof definition.projectRoot === "string" &&
|
|
1302
|
+
isAbsolute(definition.projectRoot) &&
|
|
1303
|
+
definition.key ===
|
|
1304
|
+
`${definition.kind}:${projectIdentity(definition.projectRoot)}` &&
|
|
1305
|
+
resolve(definition.path) ===
|
|
1306
|
+
resolve(join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents", "skills"));
|
|
1307
|
+
if (!validProjectRoot)
|
|
1308
|
+
return null;
|
|
1309
|
+
if (!(await projectSourceSafe(definition))) {
|
|
1310
|
+
return {
|
|
1311
|
+
ok: false,
|
|
1312
|
+
error: `项目技能目录不安全,拒绝写入状态: ${definition.path}`,
|
|
1313
|
+
code: "error.root.unsafe",
|
|
1314
|
+
params: { path: definition.path },
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
return definition;
|
|
1318
|
+
}
|
|
1319
|
+
async function setPolicySkillEnabled(root, name, enabled, log) {
|
|
1320
|
+
const definition = await checkedPolicyRootDefinition(root);
|
|
1321
|
+
if (definition && definition.ok === false)
|
|
1322
|
+
return definition;
|
|
1323
|
+
if (!definition)
|
|
1324
|
+
return readonlyError("toggle");
|
|
1325
|
+
const resolved = await resolveEntry(definition, name);
|
|
1326
|
+
if (resolved === null)
|
|
1327
|
+
return {
|
|
1328
|
+
ok: false,
|
|
1329
|
+
error: `技能不存在: ${name}`,
|
|
1330
|
+
code: "error.skill.notFound",
|
|
1331
|
+
params: { name },
|
|
1332
|
+
};
|
|
1333
|
+
let summary;
|
|
1334
|
+
try {
|
|
1335
|
+
summary = entryOf(name, resolved.kind, resolved.docPath, parseSkillDoc(await fs.readFile(resolved.realDocPath || resolved.docPath, "utf8")));
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
return {
|
|
1339
|
+
ok: false,
|
|
1340
|
+
error: `技能不存在: ${name}`,
|
|
1341
|
+
code: "error.skill.notFound",
|
|
1342
|
+
params: { name },
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
if (!summary.hasFrontmatter) {
|
|
1346
|
+
return {
|
|
1347
|
+
ok: false,
|
|
1348
|
+
error: `技能缺少完整 frontmatter,无法${enabled ? "启用" : "停用"}: ${name}`,
|
|
1349
|
+
code: "error.skill.noFrontmatter",
|
|
1350
|
+
params: { name, action: enabled ? "enable" : "disable" },
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
if (!summary.loadable) {
|
|
1354
|
+
return {
|
|
1355
|
+
ok: false,
|
|
1356
|
+
error: `技能结构不完整,无法${enabled ? "启用" : "停用"}: ${name}`,
|
|
1357
|
+
code: "error.skill.notLoadable",
|
|
1358
|
+
params: { name, action: enabled ? "enable" : "disable" },
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const current = await readManagerState();
|
|
1362
|
+
if (current.writable === false)
|
|
1363
|
+
return invalidManagerStateWrite();
|
|
1364
|
+
const disabled = new Set(current.state.disabledSkills[definition.key] || []);
|
|
1365
|
+
const explicitlyEnabled = new Set(current.state.enabledSkills[definition.key] || []);
|
|
1366
|
+
if (enabled) {
|
|
1367
|
+
disabled.delete(name);
|
|
1368
|
+
explicitlyEnabled.add(name);
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
explicitlyEnabled.delete(name);
|
|
1372
|
+
disabled.add(name);
|
|
1373
|
+
}
|
|
1374
|
+
current.state.disabledSkills[definition.key] = [...disabled].sort();
|
|
1375
|
+
current.state.enabledSkills[definition.key] = [...explicitlyEnabled].sort();
|
|
1376
|
+
await writeManagerState(current.state);
|
|
1377
|
+
if (log)
|
|
1378
|
+
log(enabled ? "policy-enable" : "policy-disable", `${enabled ? "启用" : "停用"} ${definition.key}/${name}(本地策略,源文件不变)`);
|
|
1379
|
+
return { root: definition.key, name, enabled: enabled === true };
|
|
1380
|
+
}
|
|
1381
|
+
// ── 启用 / 停用(同时控制模型与 / 手动调用,非破坏)──────────────────────────
|
|
1382
|
+
/** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
|
|
1383
|
+
export async function setSkillEnabled(root, name, enabled, log) {
|
|
1384
|
+
return setPolicySkillEnabled(root, name, enabled, log);
|
|
1385
|
+
}
|
|
1386
|
+
async function safeExistingEntryPaths(root, name) {
|
|
1387
|
+
const paths = [];
|
|
1388
|
+
const bundle = entryPath(root, name);
|
|
1389
|
+
if (bundle === null)
|
|
1390
|
+
return paths;
|
|
1391
|
+
const flat = resolve(root, `${name}.md`);
|
|
1392
|
+
const bundleStat = await lstatOrNull(bundle);
|
|
1393
|
+
if (bundleStat && (bundleStat.isDirectory() || bundleStat.isSymbolicLink()))
|
|
1394
|
+
paths.push({ path: bundle, fileName: name, recursive: true });
|
|
1395
|
+
const flatStat = await lstatOrNull(flat);
|
|
1396
|
+
if (flatStat && (flatStat.isFile() || flatStat.isSymbolicLink()))
|
|
1397
|
+
paths.push({ path: flat, fileName: `${name}.md`, recursive: false });
|
|
1398
|
+
return paths;
|
|
1399
|
+
}
|
|
1400
|
+
async function readTrashMetadata(id) {
|
|
1401
|
+
if (entryPath(trashRootPath(), id) === null)
|
|
1402
|
+
return null;
|
|
1403
|
+
try {
|
|
1404
|
+
const value = JSON.parse(await fs.readFile(join(trashRootPath(), id, "metadata.json"), "utf8"));
|
|
1405
|
+
if (!value ||
|
|
1406
|
+
value.id !== id ||
|
|
1407
|
+
typeof value.name !== "string" ||
|
|
1408
|
+
!Array.isArray(value.entries))
|
|
1409
|
+
return null;
|
|
1410
|
+
return value;
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Windows Defender / 索引器可能持续占用刚写入的 stage 目录,导致容器目录 rename
|
|
1418
|
+
* 在短重试窗口后仍返回 EPERM。此时保留 stage 作为唯一可回滚副本,逐项复制到最终目录,
|
|
1419
|
+
* 并最后写 metadata:listTrash() 在复制完整前不会暴露半成品。
|
|
1420
|
+
*/
|
|
1421
|
+
async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
|
|
1422
|
+
try {
|
|
1423
|
+
await renameWithRetry(stage, finalPath, renameOptions);
|
|
1424
|
+
return { fallback: false, cleanupError: null };
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
if (!TRANSIENT_RENAME_CODES.has(error && error.code))
|
|
1428
|
+
throw error;
|
|
1429
|
+
}
|
|
1430
|
+
await fs.mkdir(finalPath);
|
|
1431
|
+
try {
|
|
1432
|
+
for (const fileName of metadata.entries) {
|
|
1433
|
+
await fs.cp(join(stage, fileName), join(finalPath, fileName), {
|
|
1434
|
+
recursive: true,
|
|
1435
|
+
dereference: false,
|
|
1436
|
+
errorOnExist: true,
|
|
1437
|
+
force: false,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
await fs.writeFile(join(finalPath, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
1441
|
+
}
|
|
1442
|
+
catch (error) {
|
|
1443
|
+
await fs
|
|
1444
|
+
.rm(finalPath, { recursive: true, force: true })
|
|
1445
|
+
.catch(() => undefined);
|
|
1446
|
+
throw error;
|
|
1447
|
+
}
|
|
1448
|
+
let cleanupError = null;
|
|
1449
|
+
try {
|
|
1450
|
+
await fs.rm(stage, { recursive: true, force: true });
|
|
1451
|
+
}
|
|
1452
|
+
catch (error) {
|
|
1453
|
+
cleanupError = error;
|
|
1454
|
+
}
|
|
1455
|
+
return { fallback: true, cleanupError };
|
|
1456
|
+
}
|
|
1457
|
+
function trashRootMetadata(definition) {
|
|
1458
|
+
if (definition.key === "dsh")
|
|
1459
|
+
return { key: "dsh", scope: "user", label: definition.label };
|
|
1460
|
+
return {
|
|
1461
|
+
key: definition.key,
|
|
1462
|
+
scope: "project",
|
|
1463
|
+
kind: "project-dsh",
|
|
1464
|
+
projectRoot: definition.projectRoot,
|
|
1465
|
+
projectName: definition.projectName,
|
|
1466
|
+
label: definition.label,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
async function restoreRootDefinition(metadata, options = {}) {
|
|
1470
|
+
// version 1 entries predate scoped Trash and always belong to $DSH_HOME/skills.
|
|
1471
|
+
if (!metadata.root)
|
|
1472
|
+
return rootByKey("dsh");
|
|
1473
|
+
if (metadata.root.scope === "user" && metadata.root.key === "dsh")
|
|
1474
|
+
return rootByKey("dsh");
|
|
1475
|
+
if (metadata.root.scope !== "project" ||
|
|
1476
|
+
metadata.root.kind !== "project-dsh" ||
|
|
1477
|
+
typeof metadata.root.key !== "string" ||
|
|
1478
|
+
typeof metadata.root.projectRoot !== "string" ||
|
|
1479
|
+
!isAbsolute(metadata.root.projectRoot))
|
|
1480
|
+
return {
|
|
1481
|
+
ok: false,
|
|
1482
|
+
error: `回收站条目来源非法: ${metadata.id}`,
|
|
1483
|
+
code: "error.trash.invalid",
|
|
1484
|
+
params: { id: metadata.id },
|
|
1485
|
+
};
|
|
1486
|
+
const roots = await projectRoots(options.projectCwds);
|
|
1487
|
+
const normalizedProjectRoot = pathIdentity(metadata.root.projectRoot);
|
|
1488
|
+
const root = roots.find((item) => item.key === metadata.root.key &&
|
|
1489
|
+
item.kind === "project-dsh" &&
|
|
1490
|
+
pathIdentity(item.projectRoot) === normalizedProjectRoot);
|
|
1491
|
+
if (!root) {
|
|
1492
|
+
return {
|
|
1493
|
+
ok: false,
|
|
1494
|
+
error: `原项目当前不在活动工作区中,无法恢复: ${metadata.root.projectRoot}`,
|
|
1495
|
+
code: "error.trash.projectUnavailable",
|
|
1496
|
+
params: { path: metadata.root.projectRoot },
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
return checkedWritableRootDefinition(root);
|
|
1500
|
+
}
|
|
1501
|
+
/** 把用户或活动项目的 DSH 根中的单个技能移入 manager-owned 回收站。 */
|
|
1502
|
+
export async function deleteSkill(root, name, log, options = {}) {
|
|
1503
|
+
const definition = await checkedWritableRootDefinition(root);
|
|
1504
|
+
if (definition && definition.ok === false)
|
|
1505
|
+
return definition;
|
|
1506
|
+
if (!definition)
|
|
1507
|
+
return readonlyError("delete");
|
|
1508
|
+
const resolved = await resolveEntry(definition, name);
|
|
1509
|
+
if (resolved === null)
|
|
1510
|
+
return {
|
|
1511
|
+
ok: false,
|
|
1512
|
+
error: `技能不存在: ${name}`,
|
|
1513
|
+
code: "error.skill.notFound",
|
|
1514
|
+
params: { name },
|
|
1515
|
+
};
|
|
1516
|
+
const targets = await safeExistingEntryPaths(definition.path, name);
|
|
1517
|
+
const id = `${Date.now()}-${randomUUID()}`;
|
|
1518
|
+
const trashRoot = trashRootPath();
|
|
1519
|
+
const stage = join(trashRoot, `.stage-${randomUUID()}`);
|
|
1520
|
+
const finalPath = join(trashRoot, id);
|
|
1521
|
+
const moved = [];
|
|
1522
|
+
await fs.mkdir(stage, { recursive: true });
|
|
1523
|
+
try {
|
|
1524
|
+
for (const target of targets) {
|
|
1525
|
+
const destination = join(stage, target.fileName);
|
|
1526
|
+
const transferred = await movePathWithFallback(target.path, destination, options.renameOptions);
|
|
1527
|
+
if (transferred.cleanupError && log)
|
|
1528
|
+
log("trash-source-warning", `技能已跨盘移入回收站,但源盘隐藏副本等待后续清理: ${transferred.quarantine}(${transferred.cleanupError.message || transferred.cleanupError})`);
|
|
1529
|
+
moved.push({ ...target, destination });
|
|
1530
|
+
}
|
|
1531
|
+
const metadata = {
|
|
1532
|
+
version: 2,
|
|
1533
|
+
id,
|
|
1534
|
+
name,
|
|
1535
|
+
deletedAt: new Date().toISOString(),
|
|
1536
|
+
entries: moved.map((item) => item.fileName),
|
|
1537
|
+
root: trashRootMetadata(definition),
|
|
1538
|
+
};
|
|
1539
|
+
await fs.writeFile(join(stage, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
1540
|
+
const published = await publishTrashStage(stage, finalPath, metadata, options.renameOptions);
|
|
1541
|
+
if (published.cleanupError && log)
|
|
1542
|
+
log("trash-stage-warning", `回收站已发布,但临时目录等待后续清理: ${stage}(${published.cleanupError.message || published.cleanupError})`);
|
|
1543
|
+
if (log)
|
|
1544
|
+
log("trash", `移到回收站 ${name} -> ${finalPath}`);
|
|
1545
|
+
return { id, name, deletedAt: metadata.deletedAt, root: metadata.root };
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
const rollbackFailures = [];
|
|
1549
|
+
for (const item of moved.reverse()) {
|
|
1550
|
+
try {
|
|
1551
|
+
await movePathWithFallback(item.destination, item.path, options.renameOptions);
|
|
1552
|
+
}
|
|
1553
|
+
catch (rollbackError) {
|
|
1554
|
+
rollbackFailures.push({
|
|
1555
|
+
path: item.destination,
|
|
1556
|
+
error: String(rollbackError && rollbackError.message
|
|
1557
|
+
? rollbackError.message
|
|
1558
|
+
: rollbackError),
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (rollbackFailures.length) {
|
|
1563
|
+
const causeText = String(error && error.message ? error.message : error);
|
|
1564
|
+
throw codedError(`${causeText};移入回收站回滚失败,未恢复内容保留在: ${stage}`, "error.trash.rollbackFailed", { path: stage, error: causeText });
|
|
1565
|
+
}
|
|
1566
|
+
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
1567
|
+
throw error;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
export async function listTrash() {
|
|
1571
|
+
let items;
|
|
1572
|
+
try {
|
|
1573
|
+
items = await fs.readdir(trashRootPath(), { withFileTypes: true });
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
return [];
|
|
1577
|
+
}
|
|
1578
|
+
const result = [];
|
|
1579
|
+
for (const item of items) {
|
|
1580
|
+
if (!item.isDirectory() || item.name.startsWith("."))
|
|
1581
|
+
continue;
|
|
1582
|
+
const metadata = await readTrashMetadata(item.name);
|
|
1583
|
+
if (metadata)
|
|
1584
|
+
result.push(metadata);
|
|
1585
|
+
}
|
|
1586
|
+
return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
|
|
1587
|
+
}
|
|
1588
|
+
export async function restoreTrash(id, log, options = {}) {
|
|
1589
|
+
const metadata = await readTrashMetadata(id);
|
|
1590
|
+
if (!metadata)
|
|
1591
|
+
return {
|
|
1592
|
+
ok: false,
|
|
1593
|
+
error: `回收站条目不存在: ${id}`,
|
|
1594
|
+
code: "error.trash.notFound",
|
|
1595
|
+
params: { id },
|
|
1596
|
+
};
|
|
1597
|
+
const definition = await restoreRootDefinition(metadata, options);
|
|
1598
|
+
if (!definition || definition.ok === false)
|
|
1599
|
+
return definition || readonlyError("restore");
|
|
1600
|
+
const root = definition.path;
|
|
1601
|
+
const conflicts = await safeExistingEntryPaths(root, metadata.name);
|
|
1602
|
+
if (conflicts.length)
|
|
1603
|
+
return {
|
|
1604
|
+
ok: false,
|
|
1605
|
+
error: `无法恢复,同名技能已存在: ${metadata.name}`,
|
|
1606
|
+
code: "error.trash.conflict",
|
|
1607
|
+
params: { name: metadata.name },
|
|
1608
|
+
};
|
|
1609
|
+
await fs.mkdir(root, { recursive: true });
|
|
1610
|
+
const itemRoot = join(trashRootPath(), id);
|
|
1611
|
+
const moved = [];
|
|
1612
|
+
try {
|
|
1613
|
+
for (const fileName of metadata.entries) {
|
|
1614
|
+
const source = join(itemRoot, fileName);
|
|
1615
|
+
const destination = join(root, fileName);
|
|
1616
|
+
if (!isSameOrDescendant(itemRoot, source) ||
|
|
1617
|
+
!isSameOrDescendant(root, destination))
|
|
1618
|
+
throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
|
|
1619
|
+
await movePathWithFallback(source, destination, options.renameOptions);
|
|
1620
|
+
moved.push({ source, destination });
|
|
1621
|
+
}
|
|
1622
|
+
await fs.rm(itemRoot, { recursive: true, force: true });
|
|
1623
|
+
if (log)
|
|
1624
|
+
log("restore", `从回收站恢复 ${metadata.name} -> ${root}`);
|
|
1625
|
+
return { id, name: metadata.name, root: trashRootMetadata(definition) };
|
|
1626
|
+
}
|
|
1627
|
+
catch (error) {
|
|
1628
|
+
for (const item of moved.reverse())
|
|
1629
|
+
await movePathWithFallback(item.destination, item.source, options.renameOptions).catch(() => undefined);
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
export async function permanentlyDeleteTrash(id, log) {
|
|
1634
|
+
const metadata = await readTrashMetadata(id);
|
|
1635
|
+
if (!metadata)
|
|
1636
|
+
return {
|
|
1637
|
+
ok: false,
|
|
1638
|
+
error: `回收站条目不存在: ${id}`,
|
|
1639
|
+
code: "error.trash.notFound",
|
|
1640
|
+
params: { id },
|
|
1641
|
+
};
|
|
1642
|
+
await fs.rm(join(trashRootPath(), id), { recursive: true, force: true });
|
|
1643
|
+
if (log)
|
|
1644
|
+
log("trash-delete", `永久删除回收站条目 ${metadata.name} (${id})`);
|
|
1645
|
+
return { id, name: metadata.name };
|
|
1646
|
+
}
|
|
1647
|
+
// ── 导入 ────────────────────────────────────────────────────────────────────
|
|
1648
|
+
/** 分析来源:单 skill 目录 / 单 .md 文件 / 批量目录。 */
|
|
1649
|
+
async function analyzeSource(source) {
|
|
1650
|
+
let st;
|
|
1651
|
+
try {
|
|
1652
|
+
st = await fs.lstat(source);
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
return {
|
|
1656
|
+
kind: "none",
|
|
1657
|
+
error: `路径不存在: ${source}`,
|
|
1658
|
+
code: "error.source.notFound",
|
|
1659
|
+
params: { path: source },
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
if (st.isSymbolicLink())
|
|
1663
|
+
return {
|
|
1664
|
+
kind: "none",
|
|
1665
|
+
error: `不支持包含符号链接的 skill 来源: ${source}`,
|
|
1666
|
+
code: "error.source.symlink",
|
|
1667
|
+
params: { path: source },
|
|
1668
|
+
};
|
|
1669
|
+
if (st.isDirectory()) {
|
|
1670
|
+
const sk = join(source, "SKILL.md");
|
|
1671
|
+
const skSt = await lstatOrNull(sk);
|
|
1672
|
+
// 预检与实际导入口径一致:SKILL.md 本身是链接时直接拒绝,避免 dry-run 通过、正式导入才失败。
|
|
1673
|
+
if (skSt && skSt.isSymbolicLink())
|
|
1674
|
+
return {
|
|
1675
|
+
kind: "none",
|
|
1676
|
+
error: `不支持包含符号链接的 skill 来源: ${sk}`,
|
|
1677
|
+
code: "error.source.symlink",
|
|
1678
|
+
params: { path: sk },
|
|
1679
|
+
};
|
|
1680
|
+
if (skSt && skSt.isFile()) {
|
|
1681
|
+
return {
|
|
1682
|
+
kind: "single",
|
|
1683
|
+
rawName: basename(source),
|
|
1684
|
+
kebab: toKebab(basename(source)),
|
|
1685
|
+
source,
|
|
1686
|
+
isDir: true,
|
|
1687
|
+
skillFile: sk,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
return { kind: "batch", rawName: basename(source), source, isDir: true };
|
|
1691
|
+
}
|
|
1692
|
+
if (st.isFile() && source.toLowerCase().endsWith(".md")) {
|
|
1693
|
+
if (basename(source).toLowerCase() === "skill.md") {
|
|
1694
|
+
const parent = dirname(source);
|
|
1695
|
+
return {
|
|
1696
|
+
kind: "single",
|
|
1697
|
+
rawName: basename(parent),
|
|
1698
|
+
kebab: toKebab(basename(parent)),
|
|
1699
|
+
source: parent,
|
|
1700
|
+
isDir: true,
|
|
1701
|
+
skillFile: source,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
const rawName = basename(source).slice(0, -3);
|
|
1705
|
+
return {
|
|
1706
|
+
kind: "single",
|
|
1707
|
+
rawName,
|
|
1708
|
+
kebab: toKebab(rawName),
|
|
1709
|
+
source,
|
|
1710
|
+
isDir: false,
|
|
1711
|
+
skillFile: source,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
return {
|
|
1715
|
+
kind: "none",
|
|
1716
|
+
error: `无法识别的 skill 来源: ${source}`,
|
|
1717
|
+
code: "error.source.unrecognized",
|
|
1718
|
+
params: { path: source },
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
async function collectCandidates(dir) {
|
|
1722
|
+
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
1723
|
+
const out = [];
|
|
1724
|
+
for (const it of items) {
|
|
1725
|
+
if (it.isSymbolicLink())
|
|
1726
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`, "error.source.symlink", { path: join(dir, it.name) });
|
|
1727
|
+
if (it.isDirectory()) {
|
|
1728
|
+
const sk = join(dir, it.name, "SKILL.md");
|
|
1729
|
+
// lstatOrNull 吞掉 IO 异常(返回 null 即跳过);symlink 必须抛出,不能被“跳过”逻辑掩盖。
|
|
1730
|
+
const st = await lstatOrNull(sk);
|
|
1731
|
+
if (st && st.isSymbolicLink())
|
|
1732
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${sk}`, "error.source.symlink", { path: sk });
|
|
1733
|
+
if (st && st.isFile()) {
|
|
1734
|
+
out.push({
|
|
1735
|
+
source: join(dir, it.name),
|
|
1736
|
+
kebab: toKebab(it.name),
|
|
1737
|
+
rawName: it.name,
|
|
1738
|
+
isDir: true,
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
else if (it.isFile() &&
|
|
1743
|
+
it.name.toLowerCase().endsWith(".md") &&
|
|
1744
|
+
it.name.toLowerCase() !== "skill.md") {
|
|
1745
|
+
out.push({
|
|
1746
|
+
source: join(dir, it.name),
|
|
1747
|
+
kebab: toKebab(it.name.slice(0, -3)),
|
|
1748
|
+
rawName: it.name.slice(0, -3),
|
|
1749
|
+
isDir: false,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return out;
|
|
1754
|
+
}
|
|
1755
|
+
/** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
|
|
1756
|
+
async function assertNoSymbolicLinks(source) {
|
|
1757
|
+
const pending = [{ path: source, depth: 0 }];
|
|
1758
|
+
while (pending.length) {
|
|
1759
|
+
const current = pending.pop();
|
|
1760
|
+
if (current.depth > MAX_SOURCE_DEPTH)
|
|
1761
|
+
throw codedError(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`, "error.source.tooDeep", { depth: MAX_SOURCE_DEPTH, path: source });
|
|
1762
|
+
const st = await fs.lstat(current.path);
|
|
1763
|
+
if (st.isSymbolicLink())
|
|
1764
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${current.path}`, "error.source.symlink", { path: current.path });
|
|
1765
|
+
if (!st.isDirectory())
|
|
1766
|
+
continue;
|
|
1767
|
+
const items = await fs.readdir(current.path, { withFileTypes: true });
|
|
1768
|
+
for (const item of items) {
|
|
1769
|
+
const path = join(current.path, item.name);
|
|
1770
|
+
if (item.isSymbolicLink())
|
|
1771
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${path}`, "error.source.symlink", { path });
|
|
1772
|
+
if (item.isDirectory())
|
|
1773
|
+
pending.push({ path, depth: current.depth + 1 });
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function temporaryPath(target, kind) {
|
|
1778
|
+
return join(dirname(target), `.${basename(target)}.dssm-${kind}-${randomUUID()}`);
|
|
1779
|
+
}
|
|
1780
|
+
/** dry-run 预检执行与正式导入相同的符号链接/深度检查,预检失败即结论,不再进入覆盖确认。
|
|
1781
|
+
* 预检与实导之间来源被替换的竞态仍由实导阶段的复制后校验兜底。 */
|
|
1782
|
+
async function preflightCandidates(pending, conflicts, failed) {
|
|
1783
|
+
for (const group of [pending, conflicts]) {
|
|
1784
|
+
for (let i = group.length - 1; i >= 0; i--) {
|
|
1785
|
+
const candidate = group[i];
|
|
1786
|
+
try {
|
|
1787
|
+
await assertNoSymbolicLinks(candidate.source);
|
|
1788
|
+
}
|
|
1789
|
+
catch (error) {
|
|
1790
|
+
failed.push(attachCode({
|
|
1791
|
+
source: candidate.source,
|
|
1792
|
+
error: String(error && error.message ? error.message : error),
|
|
1793
|
+
}, error));
|
|
1794
|
+
group.splice(i, 1);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
/** 先复制到同目录临时路径,复制失败时不触碰现有技能。 */
|
|
1800
|
+
async function copyToTemporary(source, target, isDir) {
|
|
1801
|
+
const temp = temporaryPath(target, "stage");
|
|
1802
|
+
try {
|
|
1803
|
+
await assertNoSymbolicLinks(source);
|
|
1804
|
+
if (isDir)
|
|
1805
|
+
await fs.cp(source, temp, { recursive: true, dereference: false });
|
|
1806
|
+
else
|
|
1807
|
+
await fs.copyFile(source, temp);
|
|
1808
|
+
await assertNoSymbolicLinks(temp);
|
|
1809
|
+
return temp;
|
|
1810
|
+
}
|
|
1811
|
+
catch (error) {
|
|
1812
|
+
await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
/** 临时副本就绪后再替换;替换失败时尽力恢复旧条目。 */
|
|
1817
|
+
async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
1818
|
+
const stage = await copyToTemporary(source, dest, isDir);
|
|
1819
|
+
const backups = [];
|
|
1820
|
+
try {
|
|
1821
|
+
for (const path of existing) {
|
|
1822
|
+
const backup = temporaryPath(path, "backup");
|
|
1823
|
+
await fs.rename(path, backup);
|
|
1824
|
+
backups.push({ path, backup });
|
|
1825
|
+
}
|
|
1826
|
+
await fs.rename(stage, dest);
|
|
1827
|
+
}
|
|
1828
|
+
catch (error) {
|
|
1829
|
+
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
1830
|
+
const rollbackFailures = [];
|
|
1831
|
+
for (const item of backups.reverse()) {
|
|
1832
|
+
try {
|
|
1833
|
+
await fs.rename(item.backup, item.path);
|
|
1834
|
+
}
|
|
1835
|
+
catch (rollbackError) {
|
|
1836
|
+
rollbackFailures.push(item.backup);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (rollbackFailures.length) {
|
|
1840
|
+
const causeText = String(error && error.message ? error.message : error);
|
|
1841
|
+
throw codedError(`${causeText};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`, "error.import.rollbackFailed", { path: rollbackFailures.join("; "), error: causeText });
|
|
1842
|
+
}
|
|
1843
|
+
throw error;
|
|
1844
|
+
}
|
|
1845
|
+
const warnings = [];
|
|
1846
|
+
for (const item of backups) {
|
|
1847
|
+
try {
|
|
1848
|
+
await fs.rm(item.backup, { recursive: true, force: true });
|
|
1849
|
+
}
|
|
1850
|
+
catch (error) {
|
|
1851
|
+
warnings.push({
|
|
1852
|
+
code: "warning.backupUncleaned",
|
|
1853
|
+
params: {
|
|
1854
|
+
path: item.backup,
|
|
1855
|
+
error: String(error && error.message ? error.message : error),
|
|
1856
|
+
},
|
|
1857
|
+
error: `旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
return warnings;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* 导入技能到目标根。
|
|
1865
|
+
* options: { conflict: 'skip'|'overwrite', dryRun: boolean }
|
|
1866
|
+
* 成功返回 { kind, imported, skipped, failed };失败返回 { ok:false, error }。
|
|
1867
|
+
*/
|
|
1868
|
+
export async function importSkill(source, log, options = {}) {
|
|
1869
|
+
const targetRoot = dshRootPath();
|
|
1870
|
+
const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
|
|
1871
|
+
const dryRun = options.dryRun === true;
|
|
1872
|
+
const analysis = await analyzeSource(source);
|
|
1873
|
+
if (analysis.kind === "none")
|
|
1874
|
+
return {
|
|
1875
|
+
ok: false,
|
|
1876
|
+
error: analysis.error || "无法识别的 skill 来源",
|
|
1877
|
+
code: analysis.code || "error.source.unrecognized",
|
|
1878
|
+
params: analysis.params,
|
|
1879
|
+
};
|
|
1880
|
+
if (await pathsOverlap(analysis.source, targetRoot))
|
|
1881
|
+
return {
|
|
1882
|
+
ok: false,
|
|
1883
|
+
error: "导入来源不能与 DSH 技能目录相同、包含或位于其中",
|
|
1884
|
+
code: "error.import.overlap",
|
|
1885
|
+
};
|
|
1886
|
+
let candidates = [];
|
|
1887
|
+
if (analysis.kind === "single") {
|
|
1888
|
+
candidates = [
|
|
1889
|
+
{
|
|
1890
|
+
source: analysis.source,
|
|
1891
|
+
kebab: analysis.kebab,
|
|
1892
|
+
rawName: analysis.rawName,
|
|
1893
|
+
isDir: analysis.isDir,
|
|
1894
|
+
},
|
|
1895
|
+
];
|
|
1896
|
+
}
|
|
1897
|
+
else {
|
|
1898
|
+
try {
|
|
1899
|
+
candidates = await collectCandidates(source);
|
|
1900
|
+
}
|
|
1901
|
+
catch (error) {
|
|
1902
|
+
return attachCode({
|
|
1903
|
+
ok: false,
|
|
1904
|
+
error: String(error && error.message ? error.message : error),
|
|
1905
|
+
}, error);
|
|
1906
|
+
}
|
|
1907
|
+
if (candidates.length === 0)
|
|
1908
|
+
return {
|
|
1909
|
+
ok: false,
|
|
1910
|
+
error: `目录下未找到任何 skill 条目(需含 SKILL.md 的子目录或 .md 文件): ${source}`,
|
|
1911
|
+
code: "error.import.emptySource",
|
|
1912
|
+
params: { path: source },
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
const pending = [];
|
|
1916
|
+
const conflicts = [];
|
|
1917
|
+
const failed = [];
|
|
1918
|
+
const imported = [];
|
|
1919
|
+
const skipped = [];
|
|
1920
|
+
const nameCount = new Map();
|
|
1921
|
+
for (const candidate of candidates) {
|
|
1922
|
+
if (candidate.kebab &&
|
|
1923
|
+
KEBAB_RE.test(candidate.kebab) &&
|
|
1924
|
+
entryPath(targetRoot, candidate.kebab) !== null)
|
|
1925
|
+
nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
1926
|
+
}
|
|
1927
|
+
function failureResult() {
|
|
1928
|
+
return {
|
|
1929
|
+
ok: false,
|
|
1930
|
+
// 聚合失败明细的原文;前端优先展示已翻译的 failed 明细,此处仅作兜底。
|
|
1931
|
+
error: failed.map((item) => item.error).join(";"),
|
|
1932
|
+
code: "error.import.failed",
|
|
1933
|
+
kind: analysis.kind,
|
|
1934
|
+
imported,
|
|
1935
|
+
skipped,
|
|
1936
|
+
failed,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
for (const c of candidates) {
|
|
1940
|
+
if (!c.kebab ||
|
|
1941
|
+
!KEBAB_RE.test(c.kebab) ||
|
|
1942
|
+
entryPath(targetRoot, c.kebab) === null) {
|
|
1943
|
+
failed.push({
|
|
1944
|
+
source: c.source,
|
|
1945
|
+
error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})`,
|
|
1946
|
+
code: "error.import.invalidName",
|
|
1947
|
+
params: { name: c.rawName || basename(c.source) },
|
|
1948
|
+
});
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (nameCount.get(c.kebab) > 1) {
|
|
1952
|
+
failed.push({
|
|
1953
|
+
source: c.source,
|
|
1954
|
+
error: `批量来源中存在多个同名插件: ${c.kebab}`,
|
|
1955
|
+
code: "error.import.duplicateName",
|
|
1956
|
+
params: { name: c.kebab },
|
|
1957
|
+
});
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
const dest = c.isDir
|
|
1961
|
+
? join(targetRoot, c.kebab)
|
|
1962
|
+
: join(targetRoot, `${c.kebab}.md`);
|
|
1963
|
+
const paths = [
|
|
1964
|
+
join(targetRoot, c.kebab),
|
|
1965
|
+
join(targetRoot, `${c.kebab}.md`),
|
|
1966
|
+
];
|
|
1967
|
+
const existing = [];
|
|
1968
|
+
for (const path of paths) {
|
|
1969
|
+
try {
|
|
1970
|
+
await fs.stat(path);
|
|
1971
|
+
existing.push(path);
|
|
1972
|
+
}
|
|
1973
|
+
catch { }
|
|
1974
|
+
}
|
|
1975
|
+
if (existing.length) {
|
|
1976
|
+
conflicts.push({
|
|
1977
|
+
name: c.kebab,
|
|
1978
|
+
source: c.source,
|
|
1979
|
+
isDir: c.isDir,
|
|
1980
|
+
paths: existing,
|
|
1981
|
+
});
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
|
|
1985
|
+
}
|
|
1986
|
+
if (pending.length === 0 && conflicts.length === 0)
|
|
1987
|
+
return failureResult();
|
|
1988
|
+
if (dryRun) {
|
|
1989
|
+
// 预检即结论:与正式导入同口径执行符号链接/深度检查,避免预检通过、确认覆盖后实导才失败。
|
|
1990
|
+
await preflightCandidates(pending, conflicts, failed);
|
|
1991
|
+
if (pending.length === 0 && conflicts.length === 0)
|
|
1992
|
+
return failureResult();
|
|
1993
|
+
return { kind: analysis.kind, pending, conflicts, failed };
|
|
1994
|
+
}
|
|
1995
|
+
if (pending.length > 0 ||
|
|
1996
|
+
(conflict === "overwrite" && conflicts.length > 0)) {
|
|
1997
|
+
await fs.mkdir(targetRoot, { recursive: true });
|
|
1998
|
+
}
|
|
1999
|
+
for (const p of pending) {
|
|
2000
|
+
try {
|
|
2001
|
+
const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
|
|
2002
|
+
imported.push({ name: p.name, overwritten: false, warnings });
|
|
2003
|
+
if (log)
|
|
2004
|
+
log("import", `导入 ${p.source} -> ${p.dest}`);
|
|
2005
|
+
}
|
|
2006
|
+
catch (e) {
|
|
2007
|
+
failed.push(attachCode({ source: p.source, error: String(e && e.message ? e.message : e) }, e));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (conflict === "overwrite") {
|
|
2011
|
+
for (const c of conflicts) {
|
|
2012
|
+
try {
|
|
2013
|
+
const dest = c.isDir
|
|
2014
|
+
? join(targetRoot, c.name)
|
|
2015
|
+
: join(targetRoot, `${c.name}.md`);
|
|
2016
|
+
const warnings = await replaceWithCopy(c.source, dest, c.isDir, c.paths);
|
|
2017
|
+
imported.push({ name: c.name, overwritten: true, warnings });
|
|
2018
|
+
if (log)
|
|
2019
|
+
log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
|
|
2020
|
+
}
|
|
2021
|
+
catch (e) {
|
|
2022
|
+
failed.push(attachCode({ source: c.source, error: String(e && e.message ? e.message : e) }, e));
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
else {
|
|
2027
|
+
for (const c of conflicts)
|
|
2028
|
+
skipped.push({ name: c.name, source: c.source });
|
|
2029
|
+
}
|
|
2030
|
+
if (failed.length && imported.length === 0)
|
|
2031
|
+
return failureResult();
|
|
2032
|
+
return { kind: analysis.kind, imported, skipped, failed };
|
|
2033
|
+
}
|
|
2034
|
+
// ── 浏览器上传导入 ──────────────────────────────────────────────────────────
|
|
2035
|
+
/**
|
|
2036
|
+
* 校验浏览器或 ZIP 提供的相对路径。上传内容始终写成普通文件,不解释 ZIP 的链接元数据。
|
|
2037
|
+
* 这样既不依赖浏览器泄露本机绝对路径,也不会让归档跨出管理器暂存目录。
|
|
2038
|
+
*/
|
|
2039
|
+
function normalizeUploadPath(input) {
|
|
2040
|
+
const raw = String(input == null ? "" : input).replace(/\\/g, "/");
|
|
2041
|
+
if (!raw ||
|
|
2042
|
+
raw.length > MAX_UPLOAD_PATH_LENGTH ||
|
|
2043
|
+
raw.includes("\0") ||
|
|
2044
|
+
raw.startsWith("/") ||
|
|
2045
|
+
/^[A-Za-z]:/.test(raw) ||
|
|
2046
|
+
raw.startsWith("//")) {
|
|
2047
|
+
throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
|
|
2048
|
+
path: raw,
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
const directory = raw.endsWith("/");
|
|
2052
|
+
const parts = raw
|
|
2053
|
+
.split("/")
|
|
2054
|
+
.filter((part, index, all) => directory && index === all.length - 1 ? false : true);
|
|
2055
|
+
if (!parts.length ||
|
|
2056
|
+
parts.length > MAX_SOURCE_DEPTH ||
|
|
2057
|
+
parts.some((part) => !part ||
|
|
2058
|
+
part === "." ||
|
|
2059
|
+
part === ".." ||
|
|
2060
|
+
part.length > MAX_ENTRY_NAME_LENGTH ||
|
|
2061
|
+
WINDOWS_DEVICE_NAME_RE.test(part))) {
|
|
2062
|
+
throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
|
|
2063
|
+
path: raw,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
return { path: parts.join("/"), directory };
|
|
2067
|
+
}
|
|
2068
|
+
function decodeUploadBase64(value, maxBytes, code = "error.upload.tooLarge") {
|
|
2069
|
+
const raw = String(value == null ? "" : value);
|
|
2070
|
+
const padding = raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0;
|
|
2071
|
+
const dataLength = raw.length - padding;
|
|
2072
|
+
const firstPadding = raw.indexOf("=");
|
|
2073
|
+
if (raw.length % 4 !== 0 ||
|
|
2074
|
+
(firstPadding !== -1 && firstPadding !== dataLength)) {
|
|
2075
|
+
throw codedError("上传内容不是合法 Base64", "error.upload.encoding");
|
|
2076
|
+
}
|
|
2077
|
+
const decodedLength = (raw.length / 4) * 3 - padding;
|
|
2078
|
+
if (decodedLength > maxBytes)
|
|
2079
|
+
throw codedError(`上传内容超过 ${maxBytes} 字节限制`, code, {
|
|
2080
|
+
limit: maxBytes,
|
|
2081
|
+
});
|
|
2082
|
+
// 避免对数 MiB 字符串使用带重复分组的正则;V8 可能在合法大文件上耗尽调用栈。
|
|
2083
|
+
for (let index = 0; index < dataLength; index += 1) {
|
|
2084
|
+
const char = raw.charCodeAt(index);
|
|
2085
|
+
if (!((char >= 65 && char <= 90) ||
|
|
2086
|
+
(char >= 97 && char <= 122) ||
|
|
2087
|
+
(char >= 48 && char <= 57) ||
|
|
2088
|
+
char === 43 ||
|
|
2089
|
+
char === 47)) {
|
|
2090
|
+
throw codedError("上传内容不是合法 Base64", "error.upload.encoding");
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
const bytes = Buffer.from(raw, "base64");
|
|
2094
|
+
return bytes;
|
|
2095
|
+
}
|
|
2096
|
+
function uploadError(error) {
|
|
2097
|
+
return attachCode({
|
|
2098
|
+
ok: false,
|
|
2099
|
+
error: String(error && error.message ? error.message : error),
|
|
2100
|
+
}, error);
|
|
2101
|
+
}
|
|
2102
|
+
async function writeUploadedEntries(contentRoot, entries) {
|
|
2103
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
2104
|
+
throw codedError("上传内容为空", "error.upload.empty");
|
|
2105
|
+
if (entries.length > MAX_UPLOAD_ENTRIES)
|
|
2106
|
+
throw codedError(`上传条目超过 ${MAX_UPLOAD_ENTRIES} 个`, "error.upload.tooMany", { limit: MAX_UPLOAD_ENTRIES });
|
|
2107
|
+
let total = 0;
|
|
2108
|
+
const seen = new Set();
|
|
2109
|
+
for (const entry of entries) {
|
|
2110
|
+
const normalized = normalizeUploadPath(entry && entry.path);
|
|
2111
|
+
const key = normalized.path.toLowerCase();
|
|
2112
|
+
if (seen.has(key))
|
|
2113
|
+
throw codedError(`上传内容包含重复路径: ${normalized.path}`, "error.upload.duplicate", { path: normalized.path });
|
|
2114
|
+
seen.add(key);
|
|
2115
|
+
if (normalized.directory)
|
|
2116
|
+
continue;
|
|
2117
|
+
const bytes = decodeUploadBase64(entry && entry.data, MAX_UPLOAD_ENTRY_BYTES);
|
|
2118
|
+
total += bytes.length;
|
|
2119
|
+
if (total > MAX_UPLOAD_TOTAL_BYTES)
|
|
2120
|
+
throw codedError(`上传内容总大小超过 ${MAX_UPLOAD_TOTAL_BYTES} 字节`, "error.upload.tooLarge", { limit: MAX_UPLOAD_TOTAL_BYTES });
|
|
2121
|
+
const target = join(contentRoot, ...normalized.path.split("/"));
|
|
2122
|
+
if (!isSameOrDescendant(contentRoot, target))
|
|
2123
|
+
throw codedError(`上传条目路径非法: ${normalized.path}`, "error.upload.path", { path: normalized.path });
|
|
2124
|
+
await fs.mkdir(dirname(target), { recursive: true });
|
|
2125
|
+
await fs.writeFile(target, bytes);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
async function writeUploadedZip(contentRoot, encoded) {
|
|
2129
|
+
const archive = decodeUploadBase64(encoded, MAX_UPLOAD_ARCHIVE_BYTES, "error.upload.archiveTooLarge");
|
|
2130
|
+
let count = 0;
|
|
2131
|
+
let total = 0;
|
|
2132
|
+
let files;
|
|
2133
|
+
try {
|
|
2134
|
+
files = unzipSync(archive, {
|
|
2135
|
+
filter(info) {
|
|
2136
|
+
const normalized = normalizeUploadPath(info.name);
|
|
2137
|
+
count += 1;
|
|
2138
|
+
if (count > MAX_UPLOAD_ENTRIES)
|
|
2139
|
+
throw codedError(`ZIP 条目超过 ${MAX_UPLOAD_ENTRIES} 个`, "error.upload.tooMany", { limit: MAX_UPLOAD_ENTRIES });
|
|
2140
|
+
if (!normalized.directory && info.originalSize > MAX_UPLOAD_ENTRY_BYTES)
|
|
2141
|
+
throw codedError(`ZIP 条目过大: ${normalized.path}`, "error.upload.tooLarge", { limit: MAX_UPLOAD_ENTRY_BYTES });
|
|
2142
|
+
total += normalized.directory ? 0 : info.originalSize;
|
|
2143
|
+
if (total > MAX_UPLOAD_TOTAL_BYTES)
|
|
2144
|
+
throw codedError(`ZIP 解压后总大小超过 ${MAX_UPLOAD_TOTAL_BYTES} 字节`, "error.upload.tooLarge", { limit: MAX_UPLOAD_TOTAL_BYTES });
|
|
2145
|
+
return !normalized.directory;
|
|
2146
|
+
},
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
catch (error) {
|
|
2150
|
+
if (error && /^error\./.test(String(error.code || "")))
|
|
2151
|
+
throw error;
|
|
2152
|
+
throw codedError(`ZIP 无法解压: ${String(error && error.message ? error.message : error)}`, "error.upload.zipInvalid");
|
|
2153
|
+
}
|
|
2154
|
+
const entries = Object.entries(files).map(([path, bytes]) => ({
|
|
2155
|
+
path,
|
|
2156
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
2157
|
+
}));
|
|
2158
|
+
await writeUploadedEntries(contentRoot, entries);
|
|
2159
|
+
}
|
|
2160
|
+
async function prepareUploadedSource(sessionRoot, input) {
|
|
2161
|
+
const contentRoot = join(sessionRoot, "content");
|
|
2162
|
+
await fs.mkdir(contentRoot, { recursive: true });
|
|
2163
|
+
if (input && input.zip !== undefined)
|
|
2164
|
+
await writeUploadedZip(contentRoot, input.zip);
|
|
2165
|
+
else
|
|
2166
|
+
await writeUploadedEntries(contentRoot, input && input.entries);
|
|
2167
|
+
const rootSkill = join(contentRoot, "SKILL.md");
|
|
2168
|
+
const rootSkillStat = await lstatOrNull(rootSkill);
|
|
2169
|
+
if (!rootSkillStat || !rootSkillStat.isFile())
|
|
2170
|
+
return contentRoot;
|
|
2171
|
+
const doc = parseSkillDoc(await fs.readFile(rootSkill, "utf8"));
|
|
2172
|
+
const fallback = String((input && input.name) || "uploaded-skill")
|
|
2173
|
+
.replace(/\.zip$/i, "")
|
|
2174
|
+
.replace(/^skill\.md$/i, "uploaded-skill");
|
|
2175
|
+
const skillName = toKebab(doc.map.name || fallback);
|
|
2176
|
+
if (!skillName ||
|
|
2177
|
+
!KEBAB_RE.test(skillName) ||
|
|
2178
|
+
entryPath(contentRoot, skillName) === null) {
|
|
2179
|
+
throw codedError(`无法生成合法 kebab-case 名称(原始名: ${doc.map.name || fallback})`, "error.import.invalidName", { name: doc.map.name || fallback });
|
|
2180
|
+
}
|
|
2181
|
+
const batchRoot = join(sessionRoot, "batch");
|
|
2182
|
+
const wrappedRoot = join(batchRoot, skillName);
|
|
2183
|
+
await fs.mkdir(batchRoot, { recursive: true });
|
|
2184
|
+
await fs.rename(contentRoot, wrappedRoot);
|
|
2185
|
+
return batchRoot;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* 接收浏览器读取后的内容,在 manager 私有目录暂存并复用现有原子导入链路。
|
|
2189
|
+
* input: { name, entries:[{path,data(base64)}] } 或 { name, zip:base64 }。
|
|
2190
|
+
*/
|
|
2191
|
+
export async function importUploadedSkill(input, log, options = {}) {
|
|
2192
|
+
const uploadHome = join(managerHomePath(), "uploads");
|
|
2193
|
+
const sessionRoot = join(uploadHome, `.upload-${randomUUID()}`);
|
|
2194
|
+
try {
|
|
2195
|
+
await fs.mkdir(sessionRoot, { recursive: true });
|
|
2196
|
+
const source = await prepareUploadedSource(sessionRoot, input || {});
|
|
2197
|
+
return await importSkill(source, log, options);
|
|
2198
|
+
}
|
|
2199
|
+
catch (error) {
|
|
2200
|
+
return uploadError(error);
|
|
2201
|
+
}
|
|
2202
|
+
finally {
|
|
2203
|
+
await fs
|
|
2204
|
+
.rm(sessionRoot, { recursive: true, force: true })
|
|
2205
|
+
.catch(() => undefined);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// ── 创建 / 详情 / Provider ─────────────────────────────────────────────────
|
|
2209
|
+
function yamlString(value) {
|
|
2210
|
+
return JSON.stringify(String(value));
|
|
2211
|
+
}
|
|
2212
|
+
export async function createSkill(input, log, options = {}) {
|
|
2213
|
+
const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root")
|
|
2214
|
+
? options.root
|
|
2215
|
+
: rootByKey("dsh");
|
|
2216
|
+
const definition = await checkedWritableRootDefinition(requestedRoot);
|
|
2217
|
+
if (definition && definition.ok === false)
|
|
2218
|
+
return definition;
|
|
2219
|
+
if (!definition)
|
|
2220
|
+
return readonlyError("create");
|
|
2221
|
+
const root = definition.path;
|
|
2222
|
+
const requestedName = String((input && input.name) || "").trim();
|
|
2223
|
+
const name = toKebab(requestedName);
|
|
2224
|
+
const description = String((input && input.description) || "").trim();
|
|
2225
|
+
const body = String((input && input.body) || "").trim();
|
|
2226
|
+
if (!name || !KEBAB_RE.test(name) || entryPath(root, name) === null)
|
|
2227
|
+
return {
|
|
2228
|
+
ok: false,
|
|
2229
|
+
error: `无法生成合法 kebab-case 名称(原始名: ${requestedName})`,
|
|
2230
|
+
code: "error.import.invalidName",
|
|
2231
|
+
params: { name: requestedName },
|
|
2232
|
+
};
|
|
2233
|
+
if (!description)
|
|
2234
|
+
return {
|
|
2235
|
+
ok: false,
|
|
2236
|
+
error: "技能简介不能为空",
|
|
2237
|
+
code: "error.create.descriptionRequired",
|
|
2238
|
+
};
|
|
2239
|
+
if (!body)
|
|
2240
|
+
return {
|
|
2241
|
+
ok: false,
|
|
2242
|
+
error: "技能正文不能为空",
|
|
2243
|
+
code: "error.create.bodyRequired",
|
|
2244
|
+
};
|
|
2245
|
+
if (description.length > 500 || body.length > 1 << 18)
|
|
2246
|
+
return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
|
|
2247
|
+
if (await safeExistingEntryPaths(root, name).then((items) => items.length > 0))
|
|
2248
|
+
return {
|
|
2249
|
+
ok: false,
|
|
2250
|
+
error: `同名技能已存在: ${name}`,
|
|
2251
|
+
code: "error.create.conflict",
|
|
2252
|
+
params: { name },
|
|
2253
|
+
};
|
|
2254
|
+
await fs.mkdir(root, { recursive: true });
|
|
2255
|
+
const target = entryPath(root, name);
|
|
2256
|
+
const stage = temporaryPath(target, "create");
|
|
2257
|
+
try {
|
|
2258
|
+
await fs.mkdir(stage);
|
|
2259
|
+
const content = `---\nname: ${name}\ndescription: ${yamlString(description)}\n---\n\n${body}\n`;
|
|
2260
|
+
await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
|
|
2261
|
+
await fs.rename(stage, target);
|
|
2262
|
+
}
|
|
2263
|
+
catch (error) {
|
|
2264
|
+
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
2265
|
+
throw error;
|
|
2266
|
+
}
|
|
2267
|
+
if (log)
|
|
2268
|
+
log("create", `创建 ${join(target, "SKILL.md")}`);
|
|
2269
|
+
return { name, path: join(target, "SKILL.md"), root: definition.key };
|
|
2270
|
+
}
|
|
2271
|
+
export async function skillDetail(keyOrRoot, name, options = {}) {
|
|
2272
|
+
const scopedRoots = await projectRoots(options.projectCwds);
|
|
2273
|
+
// 支持传 key 或 definition(自定义来源由 service 层解析后传入)。
|
|
2274
|
+
const root = (keyOrRoot && typeof keyOrRoot === "object" && typeof keyOrRoot.key === "string" ? keyOrRoot : null) ||
|
|
2275
|
+
rootByKey(keyOrRoot) ||
|
|
2276
|
+
scopedRoots.find((item) => item.key === keyOrRoot);
|
|
2277
|
+
if (!root)
|
|
2278
|
+
return {
|
|
2279
|
+
ok: false,
|
|
2280
|
+
error: `未知技能来源: ${key}`,
|
|
2281
|
+
code: "error.root.unknown",
|
|
2282
|
+
params: { root: key },
|
|
2283
|
+
};
|
|
2284
|
+
const entry = await visibleEntryForRoot(root, name);
|
|
2285
|
+
if (!entry)
|
|
2286
|
+
return {
|
|
2287
|
+
ok: false,
|
|
2288
|
+
error: `技能不存在: ${name}`,
|
|
2289
|
+
code: "error.skill.notFound",
|
|
2290
|
+
params: { name },
|
|
2291
|
+
};
|
|
2292
|
+
let raw;
|
|
2293
|
+
try {
|
|
2294
|
+
raw = await fs.readFile(entry.realDocPath || entry.docPath, "utf8");
|
|
2295
|
+
}
|
|
2296
|
+
catch {
|
|
2297
|
+
return {
|
|
2298
|
+
ok: false,
|
|
2299
|
+
error: `技能不存在: ${name}`,
|
|
2300
|
+
code: "error.skill.notFound",
|
|
2301
|
+
params: { name },
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
const doc = parseSkillDoc(raw);
|
|
2305
|
+
const summary = entryOf(name, entry.kind, entry.docPath, doc);
|
|
2306
|
+
return {
|
|
2307
|
+
root: root.key,
|
|
2308
|
+
name,
|
|
2309
|
+
declaredName: summary.declaredName,
|
|
2310
|
+
description: summary.description,
|
|
2311
|
+
path: entry.docPath,
|
|
2312
|
+
kind: entry.kind,
|
|
2313
|
+
body: doc.body.trim(),
|
|
2314
|
+
frontmatter: summary.hasFrontmatter
|
|
2315
|
+
? Object.fromEntries(Object.entries(doc.map))
|
|
2316
|
+
: null,
|
|
2317
|
+
diagnostics: summary.diagnostics,
|
|
2318
|
+
loadable: summary.loadable,
|
|
2319
|
+
sourceReadOnly: !root.mutable,
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
/** 生成 manager provider 候选:保留禁用候选以阻止低优先级重名副本意外激活。 */
|
|
2323
|
+
export async function listProviderCandidates(options = {}) {
|
|
2324
|
+
const policyResult = await readManagerState();
|
|
2325
|
+
const candidates = [];
|
|
2326
|
+
const user = userRoots().concat(customRootsFromState(policyResult.state));
|
|
2327
|
+
const userScans = await scanDeduplicatedRoots(user);
|
|
2328
|
+
const items = user.flatMap((root) => userScans.get(root.key).entries.map((entry) => ({ root, entry })));
|
|
2329
|
+
const cwd = options && typeof options.cwd === "string" ? options.cwd : undefined;
|
|
2330
|
+
if (cwd) {
|
|
2331
|
+
const roots = await projectRoots([cwd]);
|
|
2332
|
+
for (const root of roots) {
|
|
2333
|
+
const scanned = (await scanDeduplicatedRoots([root])).get(root.key);
|
|
2334
|
+
for (const entry of scanned.entries)
|
|
2335
|
+
items.push({ root, entry });
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
for (const group of groupLoadableSkillsByName(items).values()) {
|
|
2339
|
+
const { root, entry } = group[0];
|
|
2340
|
+
const project = root.scope === "project";
|
|
2341
|
+
const policyOnly = root.mutable;
|
|
2342
|
+
const policy = effectiveSkillPolicy(policyResult, root, entry);
|
|
2343
|
+
// 近作用域先于 rank 决胜;冲突时必须提供真正的赢家,避免预设原生副本回流。
|
|
2344
|
+
// 禁用状态不参与选赢家,禁用赢家仍需阻断低优先级副本。
|
|
2345
|
+
const needsOverlay = !policyOnly ||
|
|
2346
|
+
group.length > 1 ||
|
|
2347
|
+
policy.override !== undefined ||
|
|
2348
|
+
!entry.invocationPolicyValid ||
|
|
2349
|
+
policyResult.writable === false;
|
|
2350
|
+
if (!needsOverlay)
|
|
2351
|
+
continue;
|
|
2352
|
+
candidates.push({
|
|
2353
|
+
name: entry.declaredName,
|
|
2354
|
+
description: entry.description,
|
|
2355
|
+
invocation: {
|
|
2356
|
+
modelInvocable: policy.modelInvocable,
|
|
2357
|
+
userInvocable: policy.userInvocable,
|
|
2358
|
+
},
|
|
2359
|
+
provider: "dsh-plugin-tool-management-external",
|
|
2360
|
+
source: project
|
|
2361
|
+
? root.kind
|
|
2362
|
+
: root.key === "dsh"
|
|
2363
|
+
? "user-dsh"
|
|
2364
|
+
: CUSTOM_ROOT_KEY_RE.test(root.key)
|
|
2365
|
+
? "custom"
|
|
2366
|
+
: `agent-${root.key}`,
|
|
2367
|
+
rank: project
|
|
2368
|
+
? root.rank - 1
|
|
2369
|
+
: root.key === "dsh"
|
|
2370
|
+
? USER_DSH_POLICY_RANK
|
|
2371
|
+
: (entry.providerRank ?? root.rank),
|
|
2372
|
+
locator: {
|
|
2373
|
+
rootKey: root.key,
|
|
2374
|
+
entryName: entry.name,
|
|
2375
|
+
path: entry.docPath,
|
|
2376
|
+
realEntryPath: entry.realEntryPath,
|
|
2377
|
+
realDocPath: entry.realDocPath,
|
|
2378
|
+
},
|
|
2379
|
+
resourceBase: {
|
|
2380
|
+
kind: "directory",
|
|
2381
|
+
path: entry.kind === "bundle" ? entry.realEntryPath : root.path,
|
|
2382
|
+
},
|
|
2383
|
+
path: entry.docPath,
|
|
2384
|
+
metadata: {
|
|
2385
|
+
dshSkillsManager: {
|
|
2386
|
+
root: root.key,
|
|
2387
|
+
readOnly: !root.mutable,
|
|
2388
|
+
sourceReadOnly: !root.mutable,
|
|
2389
|
+
policyOnly,
|
|
2390
|
+
},
|
|
2391
|
+
},
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
return candidates;
|
|
2395
|
+
}
|
|
2396
|
+
export async function getProviderSkill(candidate, options = {}) {
|
|
2397
|
+
const locator = candidate && candidate.locator;
|
|
2398
|
+
if (!locator ||
|
|
2399
|
+
typeof locator.path !== "string" ||
|
|
2400
|
+
typeof locator.rootKey !== "string" ||
|
|
2401
|
+
typeof locator.realEntryPath !== "string" ||
|
|
2402
|
+
typeof locator.realDocPath !== "string")
|
|
2403
|
+
return undefined;
|
|
2404
|
+
let root = rootByKey(locator.rootKey);
|
|
2405
|
+
if (!root &&
|
|
2406
|
+
PROJECT_ROOT_KEY_RE.test(locator.rootKey) &&
|
|
2407
|
+
typeof options.cwd === "string") {
|
|
2408
|
+
root = (await projectRoots([options.cwd])).find((item) => item.key === locator.rootKey);
|
|
2409
|
+
}
|
|
2410
|
+
if (!root)
|
|
2411
|
+
return undefined;
|
|
2412
|
+
try {
|
|
2413
|
+
const entry = await resolveEntry(root, String(locator.entryName || ""));
|
|
2414
|
+
if (!entry || resolve(entry.docPath) !== resolve(locator.path))
|
|
2415
|
+
return undefined;
|
|
2416
|
+
if (pathIdentity(entry.realEntryPath) !== pathIdentity(locator.realEntryPath))
|
|
2417
|
+
return undefined;
|
|
2418
|
+
if (pathIdentity(entry.realDocPath) !== pathIdentity(locator.realDocPath))
|
|
2419
|
+
return undefined;
|
|
2420
|
+
const doc = parseSkillDoc(await fs.readFile(entry.realDocPath || entry.docPath, "utf8"));
|
|
2421
|
+
const summary = entryOf(locator.entryName, entry.kind, entry.docPath, doc);
|
|
2422
|
+
if (!summary.loadable || summary.declaredName !== candidate.name)
|
|
2423
|
+
return undefined;
|
|
2424
|
+
return {
|
|
2425
|
+
name: candidate.name,
|
|
2426
|
+
description: candidate.description,
|
|
2427
|
+
invocation: candidate.invocation,
|
|
2428
|
+
provider: candidate.provider,
|
|
2429
|
+
source: candidate.source,
|
|
2430
|
+
resourceBase: candidate.resourceBase,
|
|
2431
|
+
path: candidate.path,
|
|
2432
|
+
metadata: candidate.metadata,
|
|
2433
|
+
content: doc.body.trim(),
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
catch {
|
|
2437
|
+
return undefined;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
// ── 状态快照 ────────────────────────────────────────────────────────────────
|
|
2441
|
+
function canonicalSkillName(item) {
|
|
2442
|
+
return item.entry.declaredName || item.entry.name;
|
|
2443
|
+
}
|
|
2444
|
+
/** 真实路径去重后按声明名分组;管理页与当前工作区 provider 共用来源优先级。 */
|
|
2445
|
+
function groupLoadableSkillsByName(items) {
|
|
2446
|
+
const groups = new Map();
|
|
2447
|
+
for (const item of [...items].sort((a, b) => a.root.rank - b.root.rank)) {
|
|
2448
|
+
if (!item.entry.loadable)
|
|
2449
|
+
continue;
|
|
2450
|
+
const name = canonicalSkillName(item);
|
|
2451
|
+
const group = groups.get(name) || [];
|
|
2452
|
+
group.push(item);
|
|
2453
|
+
groups.set(name, group);
|
|
2454
|
+
}
|
|
2455
|
+
return groups;
|
|
2456
|
+
}
|
|
2457
|
+
function markWinners(items, options = {}) {
|
|
2458
|
+
const winners = new Map();
|
|
2459
|
+
for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items)) {
|
|
2460
|
+
winners.set(canonicalName, winner);
|
|
2461
|
+
if (options.markShadowed !== false) {
|
|
2462
|
+
for (const item of shadowed)
|
|
2463
|
+
item.view.shadowedBy = {
|
|
2464
|
+
root: winner.root.key,
|
|
2465
|
+
name: winner.entry.name,
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
if (options.markWinner !== false)
|
|
2469
|
+
winner.view.winner = true;
|
|
2470
|
+
winner.view.enabled = winner.policy.enabled;
|
|
2471
|
+
winner.view.canonicalName = canonicalName;
|
|
2472
|
+
}
|
|
2473
|
+
return winners;
|
|
2474
|
+
}
|
|
2475
|
+
/** DSH、常见 Agent、自定义目录与活动 Session 项目根的技能快照。 */
|
|
2476
|
+
export async function state(options = {}) {
|
|
2477
|
+
const policyResult = await readManagerState();
|
|
2478
|
+
const user = userRoots().concat(customRootsFromState(policyResult.state));
|
|
2479
|
+
const userScans = await scanDeduplicatedRoots(user);
|
|
2480
|
+
const projectWarnings = [];
|
|
2481
|
+
const scoped = await projectRoots(options.projectCwds, projectWarnings);
|
|
2482
|
+
const trash = await listTrash();
|
|
2483
|
+
const result = {
|
|
2484
|
+
roots: [],
|
|
2485
|
+
projects: [],
|
|
2486
|
+
trash,
|
|
2487
|
+
warnings: [
|
|
2488
|
+
...(policyResult.warning ? [policyResult.warning] : []),
|
|
2489
|
+
...projectWarnings,
|
|
2490
|
+
],
|
|
2491
|
+
};
|
|
2492
|
+
// 回收站没有自动清理:超龄条目通过 warnings 提示用户到 Skills 管理页处理,
|
|
2493
|
+
// 避免静默删除用户可能还想恢复的内容。
|
|
2494
|
+
const TRASH_STALE_DAYS = 30;
|
|
2495
|
+
const staleTrashCount = trash.filter((item) => {
|
|
2496
|
+
const deletedAt = Date.parse(String(item && item.deletedAt));
|
|
2497
|
+
return Number.isFinite(deletedAt) && Date.now() - deletedAt > TRASH_STALE_DAYS * 86400000;
|
|
2498
|
+
}).length;
|
|
2499
|
+
if (staleTrashCount > 0)
|
|
2500
|
+
result.warnings.push({
|
|
2501
|
+
code: "warning.trash.stale",
|
|
2502
|
+
params: { count: staleTrashCount, days: TRASH_STALE_DAYS },
|
|
2503
|
+
error: `回收站中有 ${staleTrashCount} 个条目已超过 ${TRASH_STALE_DAYS} 天,建议到 Skills 管理页清理`,
|
|
2504
|
+
});
|
|
2505
|
+
const all = [];
|
|
2506
|
+
for (const root of [...scoped, ...user]) {
|
|
2507
|
+
const { exists, entries, truncated } = root.scope === "project"
|
|
2508
|
+
? (await scanDeduplicatedRoots([root])).get(root.key)
|
|
2509
|
+
: userScans.get(root.key);
|
|
2510
|
+
// 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
|
|
2511
|
+
if (root.scope === "project" && !exists && root.kind !== "project-dsh")
|
|
2512
|
+
continue;
|
|
2513
|
+
if (truncated)
|
|
2514
|
+
result.warnings.push({
|
|
2515
|
+
code: "warning.scan.truncated",
|
|
2516
|
+
params: { path: root.path },
|
|
2517
|
+
error: `技能扫描达到遍历上限,部分技能未显示: ${root.path}`,
|
|
2518
|
+
});
|
|
2519
|
+
const skills = [];
|
|
2520
|
+
for (const e of entries) {
|
|
2521
|
+
const policy = effectiveSkillPolicy(policyResult, root, e);
|
|
2522
|
+
const managerEnabled = policyResult.writable !== false &&
|
|
2523
|
+
policy.sourceEnabled &&
|
|
2524
|
+
policy.override !== false;
|
|
2525
|
+
skills.push({
|
|
2526
|
+
name: e.name,
|
|
2527
|
+
declaredName: e.declaredName,
|
|
2528
|
+
kind: e.kind,
|
|
2529
|
+
description: e.description,
|
|
2530
|
+
modelInvocable: e.modelInvocable,
|
|
2531
|
+
userInvocable: e.userInvocable,
|
|
2532
|
+
invocationPolicyValid: e.invocationPolicyValid,
|
|
2533
|
+
hasFrontmatter: e.hasFrontmatter,
|
|
2534
|
+
loadable: e.loadable,
|
|
2535
|
+
managerEnabled,
|
|
2536
|
+
managerOverride: policy.override === undefined ? null : policy.override,
|
|
2537
|
+
effectiveModelInvocable: policy.modelInvocable,
|
|
2538
|
+
effectiveUserInvocable: policy.userInvocable,
|
|
2539
|
+
diagnostics: e.diagnostics,
|
|
2540
|
+
path: e.docPath,
|
|
2541
|
+
});
|
|
2542
|
+
all.push({
|
|
2543
|
+
root,
|
|
2544
|
+
entry: e,
|
|
2545
|
+
managerEnabled,
|
|
2546
|
+
policy,
|
|
2547
|
+
view: skills[skills.length - 1],
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
result.roots.push({
|
|
2551
|
+
key: root.key,
|
|
2552
|
+
...(root.kind ? { kind: root.kind } : {}),
|
|
2553
|
+
...(root.localeKey ? { localeKey: root.localeKey } : {}),
|
|
2554
|
+
path: root.path,
|
|
2555
|
+
label: root.label,
|
|
2556
|
+
mutable: root.mutable,
|
|
2557
|
+
toggleable: root.toggleable,
|
|
2558
|
+
native: root.native,
|
|
2559
|
+
rank: root.rank,
|
|
2560
|
+
scope: root.scope || "user",
|
|
2561
|
+
...(root.projectRoot
|
|
2562
|
+
? {
|
|
2563
|
+
projectRoot: root.projectRoot,
|
|
2564
|
+
projectName: root.projectName,
|
|
2565
|
+
workspaceCwds: root.workspaceCwds,
|
|
2566
|
+
}
|
|
2567
|
+
: {}),
|
|
2568
|
+
exists,
|
|
2569
|
+
truncated: truncated === true,
|
|
2570
|
+
enabled: policyResult.writable !== false &&
|
|
2571
|
+
(root.scope === "project" ||
|
|
2572
|
+
root.key === "dsh" ||
|
|
2573
|
+
policyResult.state.sources[root.key] !== false),
|
|
2574
|
+
skills,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
const userItems = all.filter((item) => item.root.scope !== "project");
|
|
2578
|
+
markWinners(userItems);
|
|
2579
|
+
const projectGroups = new Map();
|
|
2580
|
+
for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
|
|
2581
|
+
const group = projectGroups.get(item.root.projectRoot) || [];
|
|
2582
|
+
group.push(item);
|
|
2583
|
+
projectGroups.set(item.root.projectRoot, group);
|
|
2584
|
+
}
|
|
2585
|
+
for (const projectItems of projectGroups.values()) {
|
|
2586
|
+
// 用户级条目参与该 workspace 的优先级判定,但不把其全局视图标记为“被某项目覆盖”。
|
|
2587
|
+
const userCopies = userItems.map((item) => ({
|
|
2588
|
+
...item,
|
|
2589
|
+
view: { ...item.view },
|
|
2590
|
+
}));
|
|
2591
|
+
markWinners([...projectItems, ...userCopies]);
|
|
2592
|
+
}
|
|
2593
|
+
const seenProjects = new Set();
|
|
2594
|
+
for (const root of result.roots.filter((item) => item.scope === "project")) {
|
|
2595
|
+
if (seenProjects.has(root.projectRoot))
|
|
2596
|
+
continue;
|
|
2597
|
+
seenProjects.add(root.projectRoot);
|
|
2598
|
+
result.projects.push({
|
|
2599
|
+
root: root.projectRoot,
|
|
2600
|
+
name: root.projectName,
|
|
2601
|
+
workspaceCwds: root.workspaceCwds,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
result.summary = {
|
|
2605
|
+
total: all.length,
|
|
2606
|
+
enabled: all.filter((item) => item.view.enabled === true).length,
|
|
2607
|
+
disabled: all.filter((item) => item.entry.loadable && item.policy.enabled === false).length,
|
|
2608
|
+
issues: all.reduce((count, item) => count + item.entry.diagnostics.length + (item.view.shadowedBy ? 1 : 0), 0),
|
|
2609
|
+
};
|
|
2610
|
+
return result;
|
|
2611
|
+
}
|
|
2612
|
+
export { KEBAB_RE };
|