dsh-plugin-tool-management 0.16.6 → 0.18.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/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
package/lib/skills/core.js
CHANGED
|
@@ -13,302 +13,47 @@
|
|
|
13
13
|
// 文件写入错误由路由返回给调用方。
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join, basename, dirname, resolve, isAbsolute, } from "node:path";
|
|
16
|
-
import { promises as fs,
|
|
17
|
-
import {
|
|
18
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
19
|
-
import { unzipSync } from "fflate";
|
|
16
|
+
import { promises as fs, } from "node:fs";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
20
18
|
import { discoverReadonlyEntries, validDiscoveryName, } from "./readonly-discovery.js";
|
|
21
|
-
import { isSameOrDescendant
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
import { isSameOrDescendant } from "../paths.js";
|
|
20
|
+
import { OFFICE_SKILL_NAMES, OFFICIAL_SOURCE_KEY, officialOfficeRuntimeSection, } from "./official.js";
|
|
21
|
+
import { parseSkillDoc, toKebab, unquote } from "./frontmatter.js";
|
|
22
|
+
// 拆出去的公开函数照旧挂在 core.js 上(tools/skills.ts 引 toKebab,memories/* 与 ops/memory.ts
|
|
23
|
+
// 引 parseSkillDoc / unquote,契约测试也按 lib/skills/core.js 取它们)。
|
|
24
|
+
export { parseSkillDoc, toKebab, unquote } from "./frontmatter.js";
|
|
25
|
+
// 官方内置技能段与 home 解析拆到同目录的 official.ts / homes.ts 后,core.js 的公开面照旧
|
|
26
|
+
// (hub.ts、memories/*、subagents/service.ts 都按 ../skills/core.js 引 resolveDshHome)。
|
|
27
|
+
export { resolveAgentsHome, resolveDshHome } from "./homes.js";
|
|
28
|
+
// 业务错误码 / 条目路径谓词 / 可恢复 rename 的重试这一层 2026-10-01 剥到 ./core-util.js:
|
|
29
|
+
// 这几件被导入链路与技能读写**两侧同时**用到,留在本文件里就意味着任何搬出去的段都要反过来
|
|
30
|
+
// 引 core —— 那是运行时环。搬到这里,两侧都只向下引这一层。公开面照旧:entryPath 与
|
|
31
|
+
// renameWithRetry 仍有外部引方(index.ts 按 ./skills/core.js 引 renameWithRetry)。
|
|
32
|
+
export { entryPath, renameWithRetry } from "./core-util.js";
|
|
33
|
+
// 来源与路径底座(四来源 / 项目级来源 / root-state-trash 路径 / 路径比较口径)2026-10-01
|
|
34
|
+
// 剥到 ./skill-roots.js。那一层只向下引 ./official.js / ./homes.js / ./core-util.js,
|
|
35
|
+
// 不回引本文件;userRoots / projectRoots / trashRootPath 有外部引方(service.ts、
|
|
36
|
+
// system-trash.ts),而 core.js 的公开面里还挂着另外四件 —— 全部照旧从 core.js 出口走。
|
|
37
|
+
export { customRootKey, isDefaultSkillSource, managerHomePath, managerStatePath, projectRoots, trashRootPath, userRoots, } from "./skill-roots.js";
|
|
38
|
+
// 上传落盘链路(Base64 目录 / ZIP 校成来源、写进技能根目录)2026-10-01 剥到
|
|
39
|
+
// ./upload-pipeline.js;它向下引 core-util / import-pipeline / skill-roots 三层,不回引本文件。
|
|
40
|
+
// service.ts 按 ./core.js 引这两件,公开面照旧再出口一次。
|
|
41
|
+
export { importSkill, importUploadedSkill } from "./upload-pipeline.js";
|
|
42
|
+
// 技能回收站(可写根判定 / 移进移出 / 清单读写)2026-10-01 剥到 ./skill-trash.js。
|
|
43
|
+
// 四件本来在 core.js 的出口上(外部按 ./core.js 引),照旧再出口一次。
|
|
44
|
+
export { listTrash, permanentlyDeleteTrash, readTrashMetadata, restoreTrash } from "./skill-trash.js";
|
|
45
|
+
import { checkedWritableRootDefinition, movePathWithFallback, safeExistingEntryPaths, trashRootMetadata, } from "./skill-trash.js";
|
|
46
|
+
// manager 状态层(状态文档与归一 / 启停策略 / 来源扫描去重)2026-10-01 剥到 ./manager-state.js:
|
|
47
|
+
// 那三件事共用一份进程内 singleton state,拆成两层就得让一层去引另一层的私有状态,等于没拆。
|
|
48
|
+
// readManagerState / effectiveSkillPolicy / entryOf 等本来挂在 core.js 的出口上(兄弟模块按
|
|
49
|
+
// ./core.js 引),照旧再出口一次。
|
|
50
|
+
export { customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, parseBoolValue, readManagerState, scanEntries, state, writeManagerState, } from "./manager-state.js";
|
|
51
|
+
import { CUSTOM_ROOT_KEY_RE, PROJECT_ROOT_KEY_RE, customRootsFromState, effectiveSkillPolicy, entryOf, groupLoadableSkillsByName, isInsideResolvedRoot, readManagerState, scanDeduplicatedRoots, writeFileAtomically, writeManagerState, } from "./manager-state.js";
|
|
52
|
+
import { KEBAB_RE, pathsOverlap, } from "./upload-pipeline.js";
|
|
53
|
+
import { customRootKey, dshRootPath, isDefaultSkillSource, managerHomePath, managerStatePath, projectIdentity, projectRoots, projectSourceSafe, readonlyError, resolvedPath, rootByKey, rootDefinition, trashRootPath, userRoots, } from "./skill-roots.js";
|
|
54
|
+
import { codedError, entryPath, lstatOrNull, renameWithRetry, pathIdentity, temporaryPath, TRANSIENT_RENAME_CODES, } from "./core-util.js";
|
|
25
55
|
const USER_DSH_POLICY_RANK = 399;
|
|
26
|
-
const WINDOWS_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
27
|
-
const MAX_SOURCE_DEPTH = 64;
|
|
28
|
-
const MAX_ENTRY_NAME_LENGTH = 128;
|
|
29
56
|
const MAX_BROWSE_ENTRIES = 500;
|
|
30
|
-
const MAX_UPLOAD_ARCHIVE_BYTES = 32 << 20;
|
|
31
|
-
const MAX_UPLOAD_ENTRY_BYTES = 32 << 20;
|
|
32
|
-
const MAX_UPLOAD_TOTAL_BYTES = 64 << 20;
|
|
33
|
-
const MAX_UPLOAD_ENTRIES = 1000;
|
|
34
|
-
const MAX_UPLOAD_PATH_LENGTH = 512;
|
|
35
|
-
const TRANSIENT_RENAME_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
36
|
-
// ── 业务错误码 ────────────────────────────────────────────────────────────────
|
|
37
|
-
/** 构造带 code/params 的业务 Error,供导入链路 throw 后透传到失败明细。 */
|
|
38
|
-
function codedError(message, code, params) {
|
|
39
|
-
const error = new Error(message);
|
|
40
|
-
error.code = code;
|
|
41
|
-
error.params = params;
|
|
42
|
-
return error;
|
|
43
|
-
}
|
|
44
|
-
/** 把业务 Error 的 code/params 附加到失败明细;系统异常(ENOENT 等,非 error.* 前缀)保持原文。 */
|
|
45
|
-
function attachCode(item, error) {
|
|
46
|
-
// 透传并回写任意失败明细对象(形状由各调用点决定),故按 any 处理。
|
|
47
|
-
const coded = error;
|
|
48
|
-
if (coded && typeof coded.code === "string" && /^error\./.test(coded.code))
|
|
49
|
-
item.code = coded.code;
|
|
50
|
-
if (coded && coded.params)
|
|
51
|
-
item.params = coded.params;
|
|
52
|
-
return item;
|
|
53
|
-
}
|
|
54
|
-
// ── 路径解析 ────────────────────────────────────────────────────────────────
|
|
55
|
-
export function resolveDshHome() {
|
|
56
|
-
return process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
57
|
-
}
|
|
58
|
-
export function resolveAgentsHome() {
|
|
59
|
-
return process.env.DSH_AGENTS_HOME || join(homedir(), ".agents");
|
|
60
|
-
}
|
|
61
|
-
// ── 官方内置技能来源(随宿主注入会话上下文的内置技能)──────────────────────
|
|
62
|
-
//
|
|
63
|
-
// 宿主按形态注入不同的官方技能(官方源码逐一核对,2026-09-30):
|
|
64
|
-
// - 桌面版 4 个:office-docx / office-pptx / office-xlsx(dsh-skill-office 只在桌面
|
|
65
|
-
// 宿主装配,assetRoot 指到 `<resources>/runtime/office-skills`)+ diagnose-windows-
|
|
66
|
-
// sandbox-acl(dsh-sandbox-local 的门控是 `process.platform === "win32"`,rank 600)。
|
|
67
|
-
// - web 版:沙盒诊断技能由宿主注入(门控只看 win32 —— 用户 2026-09-30 截图实锤,
|
|
68
|
-
// 0.16.5 按「web 不注入」的误判把 web 挡在门外,导致页面不显示、宿主照注);office
|
|
69
|
-
// 插件 web 宿主不装配、不注入,但用户裁定(2026-09-30)技能页照样把这三个暴露出来
|
|
70
|
-
// 统一管理(npm 包自带完整 assets,接管后可启停)。
|
|
71
|
-
//
|
|
72
|
-
// 它们或住在官方 asar 里、或由官方 provider 复制进临时目录自管,插件直接扫描/接管有两个
|
|
73
|
-
// 坑:宿主子进程里对 asar 的 promises 读法与拦截器解析都出现过时灵时不灵(同机两次启动
|
|
74
|
-
// 一组 4/1、一组全 0,实测);沙盒技能的脚本要交给 PowerShell 子进程,asar 路径读不了。
|
|
75
|
-
//
|
|
76
|
-
// 因此这里把来源**物化**成插件自有的一份真实目录,**每种宿主形态各一份**
|
|
77
|
-
// (`$DSH_HOME/tool-management/official-skills/<desktop|web>/`):每次宿主启动从官方源头
|
|
78
|
-
// 整目录覆盖拷贝(与官方 provider 的「私有资源副本」同一做法),扫描、启停、overlay、
|
|
79
|
-
// 脚本执行全部落在真实文件上。599 压过宿主 provider 的 600,停用即不注入。分形态是竞态
|
|
80
|
-
// 教训:0.16.5 之前共用一份目录,web 启动把它解析到的沙盒技能盖进去,桌面物化好的
|
|
81
|
-
// 4 个只剩 1 个(实测);改成各写各的子目录,互不清对方的账。共享布局的残留(旧条目
|
|
82
|
-
// 直接躺在 official-skills/ 下)按已知名单清理。
|
|
83
|
-
//
|
|
84
|
-
// - 预设参考技能(dsh-agent-preset/skills)不注入会话上下文,不列入(用户裁定)。
|
|
85
|
-
//
|
|
86
|
-
// 单个来源、随 DSH 技能之后排列,来源层语义同默认来源(不可移除、无来源开关)。
|
|
87
|
-
const OFFICIAL_SOURCE_KEY = "official";
|
|
88
|
-
/** electron 的 resources 目录;非桌面宿主(web 等普通 node 进程)没有这个属性。 */
|
|
89
|
-
function resourcesDir() {
|
|
90
|
-
const resources = process.resourcesPath;
|
|
91
|
-
return typeof resources === "string" && resources !== "" ? resources : null;
|
|
92
|
-
}
|
|
93
|
-
/** 桌面版官方 asar 里的包内目录(同步 fs 对 asar 的支持经过实测可靠)。 */
|
|
94
|
-
function desktopPackageDir(packageName, subpath) {
|
|
95
|
-
const resources = resourcesDir();
|
|
96
|
-
if (resources === null)
|
|
97
|
-
return null;
|
|
98
|
-
const dir = join(resources, "app.asar", "dsh", "node_modules", ...packageName.split("/"), ...subpath.split("/"));
|
|
99
|
-
return existsSync(dir) ? dir : null;
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* 从插件自身的解析链找官方包内目录(web 等非桌面宿主的途径;宿主进程里的裸名
|
|
103
|
-
* 解析由官方模块拦截器路由到宿主副本)。包不可解析(旧宿主 / 单测环境)返回 null。
|
|
104
|
-
*/
|
|
105
|
-
function resolvedPackageDir(packageName, subpath) {
|
|
106
|
-
try {
|
|
107
|
-
const require = createRequire(import.meta.url);
|
|
108
|
-
let manifest;
|
|
109
|
-
try {
|
|
110
|
-
manifest = require.resolve(`${packageName}/package.json`);
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// exports 未暴露 ./package.json 的包:从入口文件向上找同名 manifest。
|
|
114
|
-
manifest = "";
|
|
115
|
-
let dir = dirname(require.resolve(packageName));
|
|
116
|
-
for (let i = 0; i < 6; i += 1) {
|
|
117
|
-
const candidate = join(dir, "package.json");
|
|
118
|
-
if (existsSync(candidate)) {
|
|
119
|
-
manifest = candidate;
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
const parent = dirname(dir);
|
|
123
|
-
if (parent === dir)
|
|
124
|
-
break;
|
|
125
|
-
dir = parent;
|
|
126
|
-
}
|
|
127
|
-
if (manifest === "")
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
const dir = join(dirname(manifest), ...subpath.split("/"));
|
|
131
|
-
return existsSync(dir) ? dir : null;
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* office 技能源头。桌面版由宿主挂载:运行时真实目录优先(官方 assetRoot),其次 asar
|
|
139
|
-
* 包内 assets。web 宿主不装配 office 插件(不注入),但 npm 包自带完整 assets —— 用户
|
|
140
|
-
* 裁定(2026-09-30)技能页照样暴露这三个技能,走宿主进程解析链取包内一份。解析不到
|
|
141
|
-
* 一律 null —— 不把拿不到真实文件的技能凭空列出来。
|
|
142
|
-
*/
|
|
143
|
-
function officialOfficeSource() {
|
|
144
|
-
const resources = resourcesDir();
|
|
145
|
-
if (resources !== null) {
|
|
146
|
-
const runtimeDir = join(resources, "runtime", "office-skills");
|
|
147
|
-
if (existsSync(join(runtimeDir, "office-docx")))
|
|
148
|
-
return runtimeDir;
|
|
149
|
-
const asarDir = desktopPackageDir("@deepseek-ai/dsh-skill-office", "assets");
|
|
150
|
-
if (asarDir !== null && existsSync(join(asarDir, "office-docx")))
|
|
151
|
-
return asarDir;
|
|
152
|
-
}
|
|
153
|
-
return resolvedPackageDir("@deepseek-ai/dsh-skill-office", "assets");
|
|
154
|
-
}
|
|
155
|
-
/** 沙盒诊断技能源头(桌面 asar 直连优先,其次宿主进程解析链;宿主门控仅看 win32,web/桌面皆注入)。 */
|
|
156
|
-
function officialSandboxSource() {
|
|
157
|
-
if (process.platform !== "win32")
|
|
158
|
-
return null;
|
|
159
|
-
return (desktopPackageDir("@deepseek-ai/dsh-sandbox-windows-acl", "assets/diagnose-windows-sandbox-acl") ??
|
|
160
|
-
resolvedPackageDir("@deepseek-ai/dsh-sandbox-windows-acl", "assets/diagnose-windows-sandbox-acl"));
|
|
161
|
-
}
|
|
162
|
-
// ── office 技能的「Installed LibreOffice Kit」段 ────────────────────────────
|
|
163
|
-
//
|
|
164
|
-
// 官方 dsh-office provider 在 get 时给三个 office 技能的正文**追加**一段
|
|
165
|
-
// 「Installed LibreOffice Kit」:SKILL.md 明文要求「使用这段提供的 libreofficeKit.node
|
|
166
|
-
// 与 .cli 绝对路径,禁止自行搜索/猜测」。我们以 599 接管后官方 provider 不再被问到,
|
|
167
|
-
// 这段必须由我们补上 —— 否则模型拿到的 office 技能缺执行 LibreOffice 命令所需的全部
|
|
168
|
-
// 路径(这个缺口 0.16.5 在桌面上就已存在)。文案与 JSON 形状逐字取自官方
|
|
169
|
-
// dsh-skill-office lib/index.js 的 officeRuntime();node/cli 的落点按宿主形态各自核实:
|
|
170
|
-
// - 桌面:官方明确要求 packaged 应用必须给**独立 node**(electron 本体不行)。
|
|
171
|
-
// 实测桌面把运行时装在 `<resources>/runtime/primary-runtime/`(DSH_PRIMARY_RUNTIME
|
|
172
|
-
// 指向它,SDK 部署可被同名环境变量改写),node 在其 dependencies/node/bin 下;cli
|
|
173
|
-
// 必须用 `app.asar.unpacked` 里的真实文件 —— 独立 node 读不了 asar 内部。
|
|
174
|
-
// - web:官方默认 process.execPath(宿主是普通 node 进程),cli 从宿主进程解析链取。
|
|
175
|
-
// 任一路径不是真实文件 → 用官方口径的「已停用」文案(SKILL.md 自有降级分支,模型不会
|
|
176
|
-
// 去猜路径),绝不给一个跑不起来的路径。
|
|
177
|
-
const OFFICE_SKILL_NAMES = new Set([
|
|
178
|
-
"office-docx",
|
|
179
|
-
"office-pptx",
|
|
180
|
-
"office-xlsx",
|
|
181
|
-
]);
|
|
182
|
-
const OFFICE_RUNTIME_DISABLED = "\n\nLibreOffice Kit is disabled in this deployment.";
|
|
183
|
-
let officeRuntimeSectionCache;
|
|
184
|
-
/** 官方 provider 在 get 时追加的 LibreOffice Kit 段(每进程算一次,路径不随会话变)。 */
|
|
185
|
-
function officialOfficeRuntimeSection() {
|
|
186
|
-
if (officeRuntimeSectionCache === undefined)
|
|
187
|
-
officeRuntimeSectionCache = computeOfficeRuntimeSection();
|
|
188
|
-
return officeRuntimeSectionCache;
|
|
189
|
-
}
|
|
190
|
-
function computeOfficeRuntimeSection() {
|
|
191
|
-
const resources = resourcesDir();
|
|
192
|
-
let node;
|
|
193
|
-
let cli;
|
|
194
|
-
const runtimeRoot = process.env.DSH_PRIMARY_RUNTIME || process.env.DSH_BUNDLED_PRIMARY_RUNTIME;
|
|
195
|
-
if (runtimeRoot) {
|
|
196
|
-
// SDK / 容器部署:官方配置从运行时根推导(cordis 装配逐字核对)。
|
|
197
|
-
node = join(resolve(runtimeRoot), "dependencies", "node", "bin", process.platform === "win32" ? "node.exe" : "node");
|
|
198
|
-
cli = resolvedPackageDir("@deepseek-ai/libreoffice-kit", "lib/cli.js");
|
|
199
|
-
}
|
|
200
|
-
else if (resources !== null) {
|
|
201
|
-
node = join(resources, "runtime", "primary-runtime", "dependencies", "node", "bin", process.platform === "win32" ? "node.exe" : "node");
|
|
202
|
-
// 桌面的 cli 只认 unpacked 真实文件(独立 node 读不了 asar;electron 的 fs 补丁
|
|
203
|
-
// 会让 existsSync 对 asar 内部也返回真,所以这里不用解析链兜底)。
|
|
204
|
-
cli = join(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "libreoffice-kit", "lib", "cli.js");
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
node = process.execPath;
|
|
208
|
-
cli = resolvedPackageDir("@deepseek-ai/libreoffice-kit", "lib/cli.js");
|
|
209
|
-
}
|
|
210
|
-
const usable = (path) => {
|
|
211
|
-
try {
|
|
212
|
-
return isAbsolute(path) && statSync(path).isFile();
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
if (!usable(node) || cli === null || !usable(cli))
|
|
219
|
-
return OFFICE_RUNTIME_DISABLED;
|
|
220
|
-
return `\n\n## Installed LibreOffice Kit\n\nUse these absolute paths for every LibreOffice Kit command. Pass the CLI entry as the first argument to Node.\n\n${JSON.stringify({ libreofficeKit: { node, cli } }, null, 2)}`;
|
|
221
|
-
}
|
|
222
|
-
function copyDirectorySync(from, to) {
|
|
223
|
-
mkdirSync(to, { recursive: true });
|
|
224
|
-
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
225
|
-
const source = join(from, entry.name);
|
|
226
|
-
const target = join(to, entry.name);
|
|
227
|
-
if (entry.isDirectory())
|
|
228
|
-
copyDirectorySync(source, target);
|
|
229
|
-
else if (entry.isFile())
|
|
230
|
-
copyFileSync(source, target);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* 物化官方内置技能到插件自有目录,返回该目录;无可用源头或拷贝失败返回 null。
|
|
235
|
-
* 每次宿主启动整目录重建(官方包只随宿主更新变化,宿主更新必然重启宿主)。
|
|
236
|
-
*/
|
|
237
|
-
let officialRootCache;
|
|
238
|
-
/** 宿主形态子目录名:桌面(有 process.resourcesPath)与 web 等普通 node 进程各物化一份。 */
|
|
239
|
-
function officialFormName() {
|
|
240
|
-
return resourcesDir() === null ? "web" : "desktop";
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* 0.16.5 之前各形态共用一份目录(旧条目直接躺在 official-skills/ 下);分形态目录后
|
|
244
|
-
* 这些残留只会变成幽灵条目,按这份已知名单清掉。只清名单内的名字,绝不碰旁边的
|
|
245
|
-
* `<form>/` 子目录 —— 那是另一种形态物化的现役目录。
|
|
246
|
-
*/
|
|
247
|
-
const LEGACY_OFFICIAL_ENTRY_NAMES = Object.freeze([
|
|
248
|
-
"office-docx",
|
|
249
|
-
"office-pptx",
|
|
250
|
-
"office-xlsx",
|
|
251
|
-
"scripts",
|
|
252
|
-
"diagnose-windows-sandbox-acl",
|
|
253
|
-
]);
|
|
254
|
-
function cleanupLegacyOfficialLayout(root) {
|
|
255
|
-
for (const name of LEGACY_OFFICIAL_ENTRY_NAMES) {
|
|
256
|
-
try {
|
|
257
|
-
rmSync(join(root, name), { recursive: true, force: true });
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
/* 残留清理失败不影响物化,下轮启动再试 */
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
function materializeOfficialSkills() {
|
|
265
|
-
const office = officialOfficeSource();
|
|
266
|
-
const sandbox = officialSandboxSource();
|
|
267
|
-
if (office === null && sandbox === null)
|
|
268
|
-
return null;
|
|
269
|
-
// 每种宿主形态只整目录重建**自己**这份(竞态教训见上方块注释);对方形态的子目录
|
|
270
|
-
// 与共享层残留清理互不干涉。
|
|
271
|
-
const root = join(resolveDshHome(), "tool-management", "official-skills", officialFormName());
|
|
272
|
-
try {
|
|
273
|
-
rmSync(root, { recursive: true, force: true });
|
|
274
|
-
// office:整个 assets 根拷进来(office-*/ 与 scripts/ 的相对结构必须保留 ——
|
|
275
|
-
// SKILL.md 用 `<skill-directory>/../scripts/check_office.py` 引用检查脚本)。
|
|
276
|
-
if (office !== null)
|
|
277
|
-
copyDirectorySync(office, root);
|
|
278
|
-
if (sandbox !== null) {
|
|
279
|
-
copyDirectorySync(sandbox, join(root, basename(sandbox)));
|
|
280
|
-
}
|
|
281
|
-
cleanupLegacyOfficialLayout(dirname(root));
|
|
282
|
-
return root;
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
console.error("[dsh-plugin-tool-management] official skills materialization failed:", String((error && error.message) || error));
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
function officialSkillRoot() {
|
|
290
|
-
if (officialRootCache !== undefined)
|
|
291
|
-
return officialRootCache;
|
|
292
|
-
const dir = materializeOfficialSkills();
|
|
293
|
-
officialRootCache =
|
|
294
|
-
dir === null
|
|
295
|
-
? null
|
|
296
|
-
: {
|
|
297
|
-
key: OFFICIAL_SOURCE_KEY,
|
|
298
|
-
path: dir,
|
|
299
|
-
label: "官方内置",
|
|
300
|
-
localeKey: "official",
|
|
301
|
-
mutable: false,
|
|
302
|
-
deletable: false,
|
|
303
|
-
// 来源层无开关(同默认来源),单个技能可启停;overlay 以 599 压过官方
|
|
304
|
-
// provider 的 600,脚本从物化副本的真实路径执行。
|
|
305
|
-
toggleable: true,
|
|
306
|
-
native: false,
|
|
307
|
-
scope: "user",
|
|
308
|
-
rank: 599,
|
|
309
|
-
};
|
|
310
|
-
return officialRootCache;
|
|
311
|
-
}
|
|
312
57
|
/**
|
|
313
58
|
* DSH 与外部 Agent 的用户级技能目录(4 个来源;外部来源由 manager provider 接入)。
|
|
314
59
|
*
|
|
@@ -317,70 +62,6 @@ function officialSkillRoot() {
|
|
|
317
62
|
* DSH 之后紧随官方内置来源(随宿主注入会话的内置技能,见 materializeOfficialSkills;
|
|
318
63
|
* 无可用源头时自然缺席)。
|
|
319
64
|
*/
|
|
320
|
-
export function userRoots() {
|
|
321
|
-
const official = officialSkillRoot();
|
|
322
|
-
return [
|
|
323
|
-
{
|
|
324
|
-
key: "dsh",
|
|
325
|
-
path: join(resolveDshHome(), "skills"),
|
|
326
|
-
label: "DSH 技能",
|
|
327
|
-
mutable: true,
|
|
328
|
-
// 默认来源:路径不可移除、不可停用(必须读取),但里面的技能可删(见 isDefaultSkillSource)。
|
|
329
|
-
deletable: true,
|
|
330
|
-
// 注意:`toggleable` 指的是「这个来源里的**单个技能**能否启停」,与来源开关无关 ——
|
|
331
|
-
// 来源层的启停/移除由 isDefaultSkillSource 判定,两者别混。
|
|
332
|
-
toggleable: true,
|
|
333
|
-
native: true,
|
|
334
|
-
rank: 400,
|
|
335
|
-
},
|
|
336
|
-
// 官方内置紧随 DSH 技能之后(用户裁定):同为一眼要看的来源,排在导入技能之前。
|
|
337
|
-
...(official === null ? [] : [official]),
|
|
338
|
-
{
|
|
339
|
-
// v0.4:插件**新建/导入**的技能落到 hub 内(用户要求:插件产生的文件收在
|
|
340
|
-
// $DSH_HOME/tool-management/)。rank 高于 DSH 技能,同名时 hub 版本遮蔽官方目录里的;
|
|
341
|
-
// 官方 ~/.dsh/skills/ 仍作为可切换来源列出(不搬走、不删)。
|
|
342
|
-
//
|
|
343
|
-
// 界面名「导入技能」:它是插件导入/新建技能的落点,不是"管理器自己的一类技能"。
|
|
344
|
-
// 与 dsh 同为默认来源:路径必须读取(不可移除、不可停用),里面的技能可删。
|
|
345
|
-
key: "hub",
|
|
346
|
-
path: join(resolveDshHome(), "tool-management", "skills"),
|
|
347
|
-
label: "导入技能",
|
|
348
|
-
localeKey: "hub",
|
|
349
|
-
mutable: true,
|
|
350
|
-
deletable: true,
|
|
351
|
-
toggleable: true,
|
|
352
|
-
native: false,
|
|
353
|
-
rank: 350,
|
|
354
|
-
},
|
|
355
|
-
{
|
|
356
|
-
key: "agents",
|
|
357
|
-
path: join(resolveAgentsHome(), "skills"),
|
|
358
|
-
label: "公共 Agent",
|
|
359
|
-
mutable: false,
|
|
360
|
-
toggleable: true,
|
|
361
|
-
native: true,
|
|
362
|
-
rank: 450,
|
|
363
|
-
},
|
|
364
|
-
{
|
|
365
|
-
key: "codex",
|
|
366
|
-
path: join(process.env.DSH_CODEX_HOME || join(homedir(), ".codex"), "skills"),
|
|
367
|
-
label: "Codex",
|
|
368
|
-
mutable: false,
|
|
369
|
-
toggleable: true,
|
|
370
|
-
native: false,
|
|
371
|
-
rank: 520,
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
key: "claude",
|
|
375
|
-
path: join(process.env.DSH_CLAUDE_HOME || join(homedir(), ".claude"), "skills"),
|
|
376
|
-
label: "Claude",
|
|
377
|
-
mutable: false,
|
|
378
|
-
toggleable: true,
|
|
379
|
-
native: false,
|
|
380
|
-
rank: 530,
|
|
381
|
-
},
|
|
382
|
-
];
|
|
383
|
-
}
|
|
384
65
|
/**
|
|
385
66
|
* 默认来源:插件自己的读写根 —— `dsh`(官方技能目录)与 `hub`(新建/导入落点)。
|
|
386
67
|
*
|
|
@@ -396,18 +77,7 @@ export function userRoots() {
|
|
|
396
77
|
*
|
|
397
78
|
* 一句话记法:**`mutable` 与 `deletable` 同向,`removable` 与 `mutable` 反向。**
|
|
398
79
|
*/
|
|
399
|
-
const DEFAULT_SOURCE_KEYS = Object.freeze(["dsh", "hub", OFFICIAL_SOURCE_KEY]);
|
|
400
80
|
/** 是否为默认来源。接受 root 对象或 key 字符串;`dsh` / `hub` / `official` 的所有特判都走这里。 */
|
|
401
|
-
export function isDefaultSkillSource(rootOrKey) {
|
|
402
|
-
const key = rootOrKey && typeof rootOrKey === "object"
|
|
403
|
-
? rootOrKey.key
|
|
404
|
-
: rootOrKey;
|
|
405
|
-
return DEFAULT_SOURCE_KEYS.includes(key);
|
|
406
|
-
}
|
|
407
|
-
function projectIdentity(path) {
|
|
408
|
-
const canonical = pathIdentity(path);
|
|
409
|
-
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
410
|
-
}
|
|
411
81
|
// ── 自定义技能目录(用户在管理页添加的任意只读来源)─────────────────────────
|
|
412
82
|
//
|
|
413
83
|
// 存储在 manager 状态文件的 customRoots 数组:{ key, path, label? }。
|
|
@@ -416,41 +86,7 @@ function projectIdentity(path) {
|
|
|
416
86
|
// 通道),启停走 sources 全局开关,停用某个技能走 disabledSkills 策略,
|
|
417
87
|
// 均不改源文件。
|
|
418
88
|
/** 由目录路径派生稳定的自定义来源 key。 */
|
|
419
|
-
export function customRootKey(path) {
|
|
420
|
-
return "custom-" + projectIdentity(path);
|
|
421
|
-
}
|
|
422
89
|
/** 从状态值解析自定义来源定义;非法条目静默丢弃,key 与 path 不匹配的不信任。 */
|
|
423
|
-
export function customRootsFromState(stateValue) {
|
|
424
|
-
const state = stateValue;
|
|
425
|
-
const list = state && Array.isArray(state.customRoots) ? state.customRoots : [];
|
|
426
|
-
const out = [];
|
|
427
|
-
for (const item of list) {
|
|
428
|
-
if (!item || typeof item !== "object")
|
|
429
|
-
continue;
|
|
430
|
-
const path = typeof item.path === "string" ? item.path : "";
|
|
431
|
-
const key = typeof item.key === "string" ? item.key : "";
|
|
432
|
-
if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
|
|
433
|
-
continue;
|
|
434
|
-
if (key !== customRootKey(path))
|
|
435
|
-
continue;
|
|
436
|
-
out.push({
|
|
437
|
-
key,
|
|
438
|
-
path,
|
|
439
|
-
label: typeof item.label === "string" && item.label.trim()
|
|
440
|
-
? item.label.trim().slice(0, 64)
|
|
441
|
-
: "自定义目录",
|
|
442
|
-
// 用户加进来的来源(相对「按约定发现」的 dsh / hub / agents / codex / claude):
|
|
443
|
-
// 界面据此决定要不要给「永久删除」——只有自定义来源能真正从插件里删掉记录。
|
|
444
|
-
custom: true,
|
|
445
|
-
mutable: false,
|
|
446
|
-
toggleable: true,
|
|
447
|
-
native: false,
|
|
448
|
-
scope: "user",
|
|
449
|
-
rank: 600,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
return out;
|
|
453
|
-
}
|
|
454
90
|
/**
|
|
455
91
|
* 添加自定义技能目录:要求绝对路径、真实存在的目录、与任何已知来源
|
|
456
92
|
* (用户根 + 现有自定义根)不重叠——重叠来源会绕过停用策略。
|
|
@@ -553,140 +189,11 @@ export async function removeCustomRoot(key, log) {
|
|
|
553
189
|
log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
|
|
554
190
|
return { key: clean, path: target.path };
|
|
555
191
|
}
|
|
556
|
-
async function nearestProjectRoot(cwd) {
|
|
557
|
-
if (typeof cwd !== "string" || !isAbsolute(cwd))
|
|
558
|
-
return null;
|
|
559
|
-
let start;
|
|
560
|
-
try {
|
|
561
|
-
start = await fs.realpath(resolve(cwd));
|
|
562
|
-
if (!(await fs.stat(start)).isDirectory())
|
|
563
|
-
return null;
|
|
564
|
-
}
|
|
565
|
-
catch (error) {
|
|
566
|
-
// 目录不存在(会话还开着、项目已被删)是最常见的一类:带上系统错误码,
|
|
567
|
-
// 调用方对 ENOENT 静默跳过 —— 报警留给真正读不了的目录。
|
|
568
|
-
const code = error && typeof error === "object" && "code" in error
|
|
569
|
-
? String(error.code)
|
|
570
|
-
: undefined;
|
|
571
|
-
return { unavailable: true, cwd, ...(code ? { code } : {}) };
|
|
572
|
-
}
|
|
573
|
-
let current = start;
|
|
574
|
-
for (;;) {
|
|
575
|
-
try {
|
|
576
|
-
await fs.lstat(join(current, ".git"));
|
|
577
|
-
return { root: current, cwd: start };
|
|
578
|
-
}
|
|
579
|
-
catch {
|
|
580
|
-
/* 继续向上查找最近的项目根 */
|
|
581
|
-
}
|
|
582
|
-
const parent = dirname(current);
|
|
583
|
-
if (parent === current)
|
|
584
|
-
return null;
|
|
585
|
-
current = parent;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
async function projectSourceSafe(definition) {
|
|
589
|
-
// 只读来源允许目录链接,但重叠来源会绕过用户根的停用策略。
|
|
590
|
-
if (definition.kind === "project-agents")
|
|
591
|
-
return !(await overlapsUserSkillRoot(definition.path));
|
|
592
|
-
const container = join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents");
|
|
593
|
-
for (const path of [container, definition.path]) {
|
|
594
|
-
const st = await lstatOrNull(path);
|
|
595
|
-
if (st && (!st.isDirectory() || st.isSymbolicLink()))
|
|
596
|
-
return false;
|
|
597
|
-
}
|
|
598
|
-
return !(await overlapsUserSkillRoot(definition.path));
|
|
599
|
-
}
|
|
600
192
|
/**
|
|
601
193
|
* 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
|
|
602
194
|
* 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
|
|
603
195
|
*/
|
|
604
|
-
export async function projectRoots(projectCwds = [], diagnostics) {
|
|
605
|
-
const projects = new Map();
|
|
606
|
-
for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
|
|
607
|
-
const found = await nearestProjectRoot(cwd);
|
|
608
|
-
if (!found || !found.root) {
|
|
609
|
-
// 目录不存在(ENOENT)= 会话开着、项目已删:这是常态而不是故障,静默跳过。
|
|
610
|
-
// 报警只留给真实读不了的目录(权限、损坏的链接等)。
|
|
611
|
-
if (found && found.unavailable && found.code === "ENOENT")
|
|
612
|
-
continue;
|
|
613
|
-
if (found &&
|
|
614
|
-
found.unavailable &&
|
|
615
|
-
Array.isArray(diagnostics) &&
|
|
616
|
-
typeof cwd === "string" &&
|
|
617
|
-
isAbsolute(cwd)) {
|
|
618
|
-
diagnostics.push({
|
|
619
|
-
code: "warning.project.unavailable",
|
|
620
|
-
params: { path: cwd },
|
|
621
|
-
error: `活动会话的项目目录不可访问,项目技能未显示: ${cwd}`,
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
const identity = pathIdentity(found.root);
|
|
627
|
-
const existing = projects.get(identity) || {
|
|
628
|
-
root: found.root,
|
|
629
|
-
cwds: new Set(),
|
|
630
|
-
};
|
|
631
|
-
existing.cwds.add(found.cwd);
|
|
632
|
-
projects.set(identity, existing);
|
|
633
|
-
}
|
|
634
|
-
const roots = [];
|
|
635
|
-
for (const project of [...projects.values()].sort((a, b) => a.root.localeCompare(b.root))) {
|
|
636
|
-
const id = projectIdentity(project.root);
|
|
637
|
-
const common = {
|
|
638
|
-
mutable: false,
|
|
639
|
-
// 未知/只读来源(外部 Agent 与自定义绝对路径)一律不可删;用户级 dsh / hub
|
|
640
|
-
// 与本仓库的项目级 .dsh/skills 都是可删的(见上方两处 `deletable: true`)。
|
|
641
|
-
deletable: false,
|
|
642
|
-
toggleable: false,
|
|
643
|
-
native: true,
|
|
644
|
-
scope: "project",
|
|
645
|
-
projectRoot: project.root,
|
|
646
|
-
projectName: basename(project.root) || project.root,
|
|
647
|
-
workspaceCwds: [...project.cwds].sort(),
|
|
648
|
-
};
|
|
649
|
-
const candidates = [
|
|
650
|
-
{
|
|
651
|
-
...common,
|
|
652
|
-
key: `project-dsh:${id}`,
|
|
653
|
-
kind: "project-dsh",
|
|
654
|
-
localeKey: "projectDsh",
|
|
655
|
-
path: join(project.root, ".dsh", "skills"),
|
|
656
|
-
label: "Project DSH",
|
|
657
|
-
rank: 100,
|
|
658
|
-
mutable: true,
|
|
659
|
-
deletable: true,
|
|
660
|
-
toggleable: true,
|
|
661
|
-
},
|
|
662
|
-
{
|
|
663
|
-
...common,
|
|
664
|
-
key: `project-agents:${id}`,
|
|
665
|
-
kind: "project-agents",
|
|
666
|
-
localeKey: "projectAgents",
|
|
667
|
-
path: join(project.root, ".agents", "skills"),
|
|
668
|
-
label: "Project Agent",
|
|
669
|
-
rank: 200,
|
|
670
|
-
toggleable: true,
|
|
671
|
-
},
|
|
672
|
-
];
|
|
673
|
-
for (const candidate of candidates) {
|
|
674
|
-
if (await projectSourceSafe(candidate))
|
|
675
|
-
roots.push(candidate);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return roots;
|
|
679
|
-
}
|
|
680
|
-
export function managerHomePath() {
|
|
681
|
-
return join(resolveDshHome(), "tool-management");
|
|
682
|
-
}
|
|
683
|
-
export function managerStatePath() {
|
|
684
|
-
return join(managerHomePath(), "skills-state.json");
|
|
685
|
-
}
|
|
686
196
|
/** 技能回收站:与其余四类同形(`hub/trash/<域>-trash/`)。 */
|
|
687
|
-
export function trashRootPath() {
|
|
688
|
-
return join(managerHomePath(), "trash", "skills-trash");
|
|
689
|
-
}
|
|
690
197
|
/** 插件日志(滚动 `.1`):收在 hub 内,不再躺在 `$DSH_HOME` 根下。 */
|
|
691
198
|
export function logPath() {
|
|
692
199
|
return join(managerHomePath(), "tool-management.log");
|
|
@@ -788,29 +295,11 @@ export async function browseDirectories(inputPath) {
|
|
|
788
295
|
}
|
|
789
296
|
return { path: canonical, home: homedir(), crumbs, entries, truncated };
|
|
790
297
|
}
|
|
791
|
-
function dshRootPath() {
|
|
792
|
-
// userRoots() 恒含 dsh 来源(见上方列表),断言只为让类型收敛。
|
|
793
|
-
return userRoots().find((root) => root.key === "dsh").path;
|
|
794
|
-
}
|
|
795
298
|
/**
|
|
796
299
|
* v0.4 新建/导入技能的落点:hub 内 `tool-management/skills/`(用户要求插件产物集中)。
|
|
797
300
|
* hub 根缺失(旧版本状态文件/异常)时退回官方 DSH 技能目录,保证创建功能永不因布局变化而失效。
|
|
798
301
|
*/
|
|
799
|
-
function skillCreateRootPath() {
|
|
800
|
-
const hub = userRoots().find((root) => root.key === "hub");
|
|
801
|
-
return hub && hub.path ? hub.path : dshRootPath();
|
|
802
|
-
}
|
|
803
302
|
/** 只读来源的拒绝结果;action 为可翻译语义值(toggle/delete)。 */
|
|
804
|
-
function readonlyError(action) {
|
|
805
|
-
return {
|
|
806
|
-
ok: false,
|
|
807
|
-
code: "error.root.readonly",
|
|
808
|
-
params: { action },
|
|
809
|
-
error: action === "delete"
|
|
810
|
-
? "该技能来源不允许删除"
|
|
811
|
-
: "该技能来源不允许启用或停用",
|
|
812
|
-
};
|
|
813
|
-
}
|
|
814
303
|
/**
|
|
815
304
|
* 来源可写但**没有删除权**时的拒绝结果 —— 防御性分支。
|
|
816
305
|
*
|
|
@@ -849,332 +338,16 @@ function reservedSourceError(root) {
|
|
|
849
338
|
error: `${origin}:不能停用或移除(里面的技能可以删除)`,
|
|
850
339
|
};
|
|
851
340
|
}
|
|
852
|
-
function rootDefinition(root) {
|
|
853
|
-
if (root && typeof root === "object" && typeof root.key === "string")
|
|
854
|
-
return root;
|
|
855
|
-
if (typeof root !== "string")
|
|
856
|
-
return null;
|
|
857
|
-
const resolved = resolve(root);
|
|
858
|
-
return userRoots().find((item) => resolve(item.path) === resolved) || null;
|
|
859
|
-
}
|
|
860
|
-
function rootByKey(key) {
|
|
861
|
-
return userRoots().find((item) => item.key === key) || null;
|
|
862
|
-
}
|
|
863
341
|
/** 只允许用户 DSH 根 / hub 根,或由活动 Session 推导出的项目 DSH 根参与文件写入。 */
|
|
864
|
-
function writableRootDefinition(root) {
|
|
865
|
-
const definition = rootDefinition(root);
|
|
866
|
-
if (!definition || definition.mutable !== true)
|
|
867
|
-
return null;
|
|
868
|
-
if (definition.key === "dsh")
|
|
869
|
-
return resolve(definition.path) === resolve(dshRootPath())
|
|
870
|
-
? rootByKey("dsh")
|
|
871
|
-
: null;
|
|
872
|
-
// v0.4:hub 内技能目录同为用户级可写根(插件新建/导入的落点)。
|
|
873
|
-
if (definition.key === "hub")
|
|
874
|
-
return resolve(definition.path) === resolve(skillCreateRootPath())
|
|
875
|
-
? rootByKey("hub")
|
|
876
|
-
: null;
|
|
877
|
-
if (definition.scope !== "project" ||
|
|
878
|
-
definition.kind !== "project-dsh" ||
|
|
879
|
-
typeof definition.projectRoot !== "string" ||
|
|
880
|
-
!isAbsolute(definition.projectRoot) ||
|
|
881
|
-
definition.key !==
|
|
882
|
-
`project-dsh:${projectIdentity(definition.projectRoot)}` ||
|
|
883
|
-
resolve(definition.path) !==
|
|
884
|
-
resolve(join(definition.projectRoot, ".dsh", "skills")))
|
|
885
|
-
return null;
|
|
886
|
-
return definition;
|
|
887
|
-
}
|
|
888
|
-
async function checkedWritableRootDefinition(root) {
|
|
889
|
-
const definition = writableRootDefinition(root);
|
|
890
|
-
if (!definition || definition.scope !== "project")
|
|
891
|
-
return definition;
|
|
892
|
-
if (await overlapsUserSkillRoot(definition.path)) {
|
|
893
|
-
return {
|
|
894
|
-
ok: false,
|
|
895
|
-
error: `项目技能目录与用户技能目录重叠,拒绝写入: ${definition.path}`,
|
|
896
|
-
code: "error.root.unsafe",
|
|
897
|
-
params: { path: definition.path },
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
// 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
|
|
901
|
-
// definition.scope === "project" 时 projectRoot 必有值(见 ProjectSource),断言只为让类型收敛。
|
|
902
|
-
for (const path of [
|
|
903
|
-
join(definition.projectRoot, ".dsh"),
|
|
904
|
-
definition.path,
|
|
905
|
-
]) {
|
|
906
|
-
const st = await lstatOrNull(path);
|
|
907
|
-
if (st && (!st.isDirectory() || st.isSymbolicLink())) {
|
|
908
|
-
return {
|
|
909
|
-
ok: false,
|
|
910
|
-
error: `项目技能目录不安全,拒绝写入: ${path}`,
|
|
911
|
-
code: "error.root.unsafe",
|
|
912
|
-
params: { path },
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
return definition;
|
|
917
|
-
}
|
|
918
342
|
/** 解析真实路径;中间若有目录链接,按落地目录比较重叠。 */
|
|
919
|
-
async function resolvedPath(path) {
|
|
920
|
-
try {
|
|
921
|
-
return await fs.realpath(path);
|
|
922
|
-
}
|
|
923
|
-
catch {
|
|
924
|
-
return resolve(path);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
343
|
/** 即使末级路径尚不存在,也解析最近既存祖先中的链接,供权限域重叠判断。 */
|
|
928
|
-
async function comparisonPath(path) {
|
|
929
|
-
let current = resolve(path);
|
|
930
|
-
const missing = [];
|
|
931
|
-
for (;;) {
|
|
932
|
-
try {
|
|
933
|
-
return resolve(await fs.realpath(current), ...missing);
|
|
934
|
-
}
|
|
935
|
-
catch {
|
|
936
|
-
const parent = dirname(current);
|
|
937
|
-
if (parent === current)
|
|
938
|
-
return resolve(path);
|
|
939
|
-
missing.unshift(basename(current));
|
|
940
|
-
current = parent;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
async function overlapsUserSkillRoot(path) {
|
|
945
|
-
const candidate = await comparisonPath(path);
|
|
946
|
-
for (const root of userRoots()) {
|
|
947
|
-
const userPath = await comparisonPath(root.path);
|
|
948
|
-
if (isSameOrDescendant(candidate, userPath) ||
|
|
949
|
-
isSameOrDescendant(userPath, candidate))
|
|
950
|
-
return true;
|
|
951
|
-
}
|
|
952
|
-
return false;
|
|
953
|
-
}
|
|
954
344
|
/** 两个路径重叠时,覆盖导入可能删除自身来源,必须拒绝。 */
|
|
955
|
-
async function pathsOverlap(a, b) {
|
|
956
|
-
const left = await resolvedPath(a);
|
|
957
|
-
const right = await resolvedPath(b);
|
|
958
|
-
return isSameOrDescendant(left, right) || isSameOrDescendant(right, left);
|
|
959
|
-
}
|
|
960
345
|
/** 预解析技能根后的根内校验,供逐条目扫描复用同一次 realpath,减少重复 IO。 */
|
|
961
|
-
async function isInsideResolvedRoot(rootReal, path) {
|
|
962
|
-
return isSameOrDescendant(rootReal, await resolvedPath(path));
|
|
963
|
-
}
|
|
964
|
-
function pathIdentity(path) {
|
|
965
|
-
const canonical = resolve(path);
|
|
966
|
-
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
967
|
-
}
|
|
968
|
-
async function lstatOrNull(path) {
|
|
969
|
-
try {
|
|
970
|
-
return await fs.lstat(path);
|
|
971
|
-
}
|
|
972
|
-
catch {
|
|
973
|
-
return null;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
/** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
|
|
977
|
-
export function entryPath(root, name) {
|
|
978
|
-
// 段名谓词收敛到 paths.js(此前与 rules / subagents / imports 各写一套,口径不一)。
|
|
979
|
-
// 原实现里 `basename(name) !== name` 那条已被谓词的「不含路径分隔符」覆盖。
|
|
980
|
-
if (!isValidSegment(name, MAX_ENTRY_NAME_LENGTH))
|
|
981
|
-
return null;
|
|
982
|
-
const rootPath = resolve(root);
|
|
983
|
-
const path = resolve(rootPath, name);
|
|
984
|
-
return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
|
|
985
|
-
}
|
|
986
346
|
function isDshRoot(root) {
|
|
987
347
|
return typeof root === "string" && resolve(root) === resolve(dshRootPath());
|
|
988
348
|
}
|
|
989
|
-
// ── 命名规整 ────────────────────────────────────────────────────────────────
|
|
990
|
-
/** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
|
|
991
|
-
export function toKebab(s) {
|
|
992
|
-
let t = String(s).trim();
|
|
993
|
-
if (t === "")
|
|
994
|
-
return "";
|
|
995
|
-
t = t.replace(/([a-z0-9])([A-Z])/g, "$1-$2"); // camelCase 边界
|
|
996
|
-
t = t.toLowerCase();
|
|
997
|
-
t = t.replace(/[\s_.]+/g, "-");
|
|
998
|
-
t = t.replace(/[^a-z0-9-]/g, "-");
|
|
999
|
-
t = t.replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1000
|
-
return t;
|
|
1001
|
-
}
|
|
1002
|
-
// ── frontmatter 解析 / 序列化(宽松 YAML 对象,保留键序)────────────────────
|
|
1003
|
-
/** 剥离 UTF-8 BOM(Windows 工具常写入,不剥离会导致开头 --- 失配)。 */
|
|
1004
|
-
function stripBom(text) {
|
|
1005
|
-
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
1006
|
-
}
|
|
1007
|
-
/** 解析 SKILL.md 的 frontmatter。返回 { fields, map, body },map 保留键序。
|
|
1008
|
-
* 只识别顶层 key: value;缩进嵌套字段(如 metadata.source)不进入 map。 */
|
|
1009
|
-
export function parseSkillDoc(text) {
|
|
1010
|
-
const src = stripBom(String(text));
|
|
1011
|
-
const lines = src.split(/\r?\n/);
|
|
1012
|
-
const map = Object.create(null);
|
|
1013
|
-
const fields = [];
|
|
1014
|
-
let body = src;
|
|
1015
|
-
let hasFrontmatter = false;
|
|
1016
|
-
if (lines.length > 0 && lines[0].trim() === "---") {
|
|
1017
|
-
let end = -1;
|
|
1018
|
-
for (let i = 1; i < lines.length; i++) {
|
|
1019
|
-
if (lines[i].trim() === "---") {
|
|
1020
|
-
end = i;
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
if (end >= 0) {
|
|
1025
|
-
hasFrontmatter = true;
|
|
1026
|
-
for (let i = 1; i < end; i++) {
|
|
1027
|
-
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[i]);
|
|
1028
|
-
if (m) {
|
|
1029
|
-
fields.push({ key: m[1], raw: m[2] });
|
|
1030
|
-
const block = /^([>|])[-+]?\s*$/.exec(m[2]);
|
|
1031
|
-
if (block) {
|
|
1032
|
-
const content = [];
|
|
1033
|
-
while (i + 1 < end &&
|
|
1034
|
-
(/^\s/.test(lines[i + 1]) || lines[i + 1] === "")) {
|
|
1035
|
-
i++;
|
|
1036
|
-
content.push(lines[i]);
|
|
1037
|
-
}
|
|
1038
|
-
const indentation = content
|
|
1039
|
-
.filter((line) => line.trim() !== "")
|
|
1040
|
-
.reduce((min, line) => Math.min(min, (/^\s*/.exec(line) || [""])[0].length), Infinity);
|
|
1041
|
-
const normalized = content.map((line) => Number.isFinite(indentation)
|
|
1042
|
-
? line.slice(Math.min(indentation, line.length))
|
|
1043
|
-
: line);
|
|
1044
|
-
map[m[1]] =
|
|
1045
|
-
block[1] === ">"
|
|
1046
|
-
? normalized.join(" ").replace(/\s+/g, " ").trim()
|
|
1047
|
-
: normalized.join("\n").trim();
|
|
1048
|
-
}
|
|
1049
|
-
else {
|
|
1050
|
-
map[m[1]] = decodeYamlScalar(m[2]);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
body = lines.slice(end + 1).join("\n");
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
return { fields, map, body, hasFrontmatter };
|
|
1058
|
-
}
|
|
1059
|
-
/** 读取 YAML 标量的显示值;不依赖第三方 YAML 解析器。 */
|
|
1060
|
-
function decodeYamlScalar(v) {
|
|
1061
|
-
const s = String(v == null ? "" : v).trim();
|
|
1062
|
-
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
1063
|
-
try {
|
|
1064
|
-
const value = JSON.parse(s);
|
|
1065
|
-
if (typeof value === "string")
|
|
1066
|
-
return value;
|
|
1067
|
-
}
|
|
1068
|
-
catch {
|
|
1069
|
-
/* 保留无法解析的原始内容 */
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'")
|
|
1073
|
-
return s.slice(1, -1).replace(/''/g, "'");
|
|
1074
|
-
return s;
|
|
1075
|
-
}
|
|
1076
|
-
export function unquote(v) {
|
|
1077
|
-
const s = String(v == null ? "" : v).trim();
|
|
1078
|
-
if (s.length >= 2 &&
|
|
1079
|
-
((s[0] === '"' && s[s.length - 1] === '"') ||
|
|
1080
|
-
(s[0] === "'" && s[s.length - 1] === "'"))) {
|
|
1081
|
-
return s.slice(1, -1);
|
|
1082
|
-
}
|
|
1083
|
-
return s;
|
|
1084
|
-
}
|
|
1085
|
-
/** 同目录临时文件加 rename,避免写入中断时截断原 SKILL.md。 */
|
|
1086
|
-
/** Windows 上杀毒软件或索引器可能短暂占用目录;只重试明确可恢复的 rename 错误。 */
|
|
1087
|
-
export async function renameWithRetry(source, destination, options = {}) {
|
|
1088
|
-
const rename = typeof options.rename === "function" ? options.rename : fs.rename;
|
|
1089
|
-
// Number.isInteger/isFinite 不收窄类型,故断言成 number 以保持既有比较表达式。
|
|
1090
|
-
const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0
|
|
1091
|
-
? options.maxAttempts
|
|
1092
|
-
: 6;
|
|
1093
|
-
const delayMs = Number.isFinite(options.delayMs) && options.delayMs >= 0
|
|
1094
|
-
? options.delayMs
|
|
1095
|
-
: 40;
|
|
1096
|
-
for (let attempt = 1;; attempt++) {
|
|
1097
|
-
try {
|
|
1098
|
-
return await rename(source, destination);
|
|
1099
|
-
}
|
|
1100
|
-
catch (error) {
|
|
1101
|
-
// 系统 rename 异常按可选 code 读取,保持既有重试判定。
|
|
1102
|
-
if (!TRANSIENT_RENAME_CODES.has(error && error.code) ||
|
|
1103
|
-
attempt >= maxAttempts)
|
|
1104
|
-
throw error;
|
|
1105
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs * attempt));
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
async function removeMovedPath(path) {
|
|
1110
|
-
const st = await lstatOrNull(path);
|
|
1111
|
-
if (!st)
|
|
1112
|
-
return;
|
|
1113
|
-
if (st.isDirectory() && !st.isSymbolicLink())
|
|
1114
|
-
await fs.rm(path, { recursive: true, force: false });
|
|
1115
|
-
else
|
|
1116
|
-
await fs.unlink(path);
|
|
1117
|
-
}
|
|
1118
349
|
/** rename 跨盘返回 EXDEV 时:先完整复制,再在源盘原子隐藏源条目,最后清理隐藏副本。 */
|
|
1119
|
-
async function movePathWithFallback(source, destination, options = {}) {
|
|
1120
|
-
try {
|
|
1121
|
-
await renameWithRetry(source, destination, options);
|
|
1122
|
-
return { copied: false, cleanupError: null };
|
|
1123
|
-
}
|
|
1124
|
-
catch (error) {
|
|
1125
|
-
// 系统异常按可选 code 读取,保持既有 EXDEV 判定。
|
|
1126
|
-
if (!error || error.code !== "EXDEV")
|
|
1127
|
-
throw error;
|
|
1128
|
-
}
|
|
1129
|
-
const quarantine = join(dirname(source), `.${basename(source)}.dssm-move-${randomUUID()}`);
|
|
1130
|
-
try {
|
|
1131
|
-
await fs.cp(source, destination, {
|
|
1132
|
-
recursive: true,
|
|
1133
|
-
dereference: false,
|
|
1134
|
-
errorOnExist: true,
|
|
1135
|
-
force: false,
|
|
1136
|
-
verbatimSymlinks: true,
|
|
1137
|
-
});
|
|
1138
|
-
// 源与 quarantine 位于同一目录;成功后原技能名立即消失,避免递归删除留下半份可见条目。
|
|
1139
|
-
await renameWithRetry(source, quarantine, options);
|
|
1140
|
-
}
|
|
1141
|
-
catch (error) {
|
|
1142
|
-
await removeMovedPath(destination).catch(() => undefined);
|
|
1143
|
-
throw error;
|
|
1144
|
-
}
|
|
1145
|
-
let cleanupError = null;
|
|
1146
|
-
try {
|
|
1147
|
-
await removeMovedPath(quarantine);
|
|
1148
|
-
}
|
|
1149
|
-
catch (error) {
|
|
1150
|
-
// 调用方按可选 message 读取该清理异常,保持既有告警文案。
|
|
1151
|
-
cleanupError = error;
|
|
1152
|
-
}
|
|
1153
|
-
return { copied: true, cleanupError, quarantine };
|
|
1154
|
-
}
|
|
1155
|
-
async function writeFileAtomically(path, content) {
|
|
1156
|
-
const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
|
|
1157
|
-
try {
|
|
1158
|
-
await fs.writeFile(temp, content, "utf8");
|
|
1159
|
-
// Windows 上目标文件会被杀软/索引器短暂占住(EPERM/EACCES/EBUSY)——
|
|
1160
|
-
// 场景模式进出时这里写的是技能来源/策略状态,rename 被撞 = 运行时已切、
|
|
1161
|
-
// 状态没落盘。与 memories-index.json 同一处理:重试瞬时占用,全失败才抛。
|
|
1162
|
-
await renameWithRetry(temp, path);
|
|
1163
|
-
}
|
|
1164
|
-
catch (error) {
|
|
1165
|
-
await fs.rm(temp, { force: true }).catch(() => undefined);
|
|
1166
|
-
throw error;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
350
|
/** 解析布尔字段值;合法布尔返回 true/false,非法返回 undefined。 */
|
|
1170
|
-
export function parseBoolValue(raw) {
|
|
1171
|
-
const v = unquote(raw).trim().toLowerCase();
|
|
1172
|
-
if (v === "true" || v === "yes" || v === "on" || v === "1")
|
|
1173
|
-
return true;
|
|
1174
|
-
if (v === "false" || v === "no" || v === "off" || v === "0")
|
|
1175
|
-
return false;
|
|
1176
|
-
return undefined;
|
|
1177
|
-
}
|
|
1178
351
|
// ── 条目定位 / 扫描 ─────────────────────────────────────────────────────────
|
|
1179
352
|
/** 按名称解析条目(bundle 优先,其次 flat)。找不到返回 null。 */
|
|
1180
353
|
export async function resolveEntry(root, name) {
|
|
@@ -1240,175 +413,8 @@ export async function resolveEntry(root, name) {
|
|
|
1240
413
|
return null;
|
|
1241
414
|
}
|
|
1242
415
|
}
|
|
1243
|
-
function entryOf(name, kind, docPath, doc) {
|
|
1244
|
-
const declaredName = doc.map.name !== undefined ? unquote(doc.map.name) : "";
|
|
1245
|
-
const description = doc.map.description !== undefined ? unquote(doc.map.description) : "";
|
|
1246
|
-
const modelValue = parseBoolValue(doc.map["disable-model-invocation"]);
|
|
1247
|
-
const userValue = parseBoolValue(doc.map["user-invocable"]);
|
|
1248
|
-
const modelDisabled = modelValue === true;
|
|
1249
|
-
const userDisabled = userValue === false;
|
|
1250
|
-
const invocationPolicyValid = (doc.map["disable-model-invocation"] === undefined ||
|
|
1251
|
-
modelValue !== undefined) &&
|
|
1252
|
-
(doc.map["user-invocable"] === undefined || userValue !== undefined);
|
|
1253
|
-
const diagnostics = [];
|
|
1254
|
-
if (!doc.hasFrontmatter)
|
|
1255
|
-
diagnostics.push({
|
|
1256
|
-
level: "error",
|
|
1257
|
-
code: "diagnostic.frontmatter.missing",
|
|
1258
|
-
});
|
|
1259
|
-
if (doc.hasFrontmatter && !declaredName)
|
|
1260
|
-
diagnostics.push({ level: "error", code: "diagnostic.name.missing" });
|
|
1261
|
-
else if (declaredName && !KEBAB_RE.test(declaredName))
|
|
1262
|
-
diagnostics.push({
|
|
1263
|
-
level: "error",
|
|
1264
|
-
code: "diagnostic.name.invalid",
|
|
1265
|
-
params: { name: declaredName },
|
|
1266
|
-
});
|
|
1267
|
-
if (doc.hasFrontmatter && !description)
|
|
1268
|
-
diagnostics.push({
|
|
1269
|
-
level: "error",
|
|
1270
|
-
code: "diagnostic.description.missing",
|
|
1271
|
-
});
|
|
1272
|
-
if (!invocationPolicyValid)
|
|
1273
|
-
diagnostics.push({ level: "error", code: "diagnostic.invocation.invalid" });
|
|
1274
|
-
return {
|
|
1275
|
-
name,
|
|
1276
|
-
declaredName,
|
|
1277
|
-
kind,
|
|
1278
|
-
docPath,
|
|
1279
|
-
description,
|
|
1280
|
-
modelInvocable: !modelDisabled,
|
|
1281
|
-
userInvocable: !userDisabled,
|
|
1282
|
-
invocationPolicyValid,
|
|
1283
|
-
hasFrontmatter: doc.hasFrontmatter,
|
|
1284
|
-
// 调用策略值异常可以由 manager 本地策略覆盖;结构本身合法即可加载。
|
|
1285
|
-
loadable: doc.hasFrontmatter && KEBAB_RE.test(declaredName) && description !== "",
|
|
1286
|
-
diagnostics,
|
|
1287
|
-
};
|
|
1288
|
-
}
|
|
1289
416
|
/** 只读来源递归发现;可写 DSH 根维持顶层扫描及链接写边界。 */
|
|
1290
|
-
export async function scanEntries(root, options = {}) {
|
|
1291
|
-
const definition = rootDefinition(root);
|
|
1292
|
-
if (definition && !definition.mutable) {
|
|
1293
|
-
const discovered = await discoverReadonlyEntries(definition.path);
|
|
1294
|
-
// metadataOnly 时条目的摘要字段由调用方按需读取(见 visibleEntryForRoot)。
|
|
1295
|
-
if (options.metadataOnly)
|
|
1296
|
-
return discovered;
|
|
1297
|
-
const entries = [];
|
|
1298
|
-
for (const entry of discovered.entries) {
|
|
1299
|
-
try {
|
|
1300
|
-
entries.push({
|
|
1301
|
-
...entryOf(entry.name, entry.kind, entry.docPath, parseSkillDoc(await fs.readFile(entry.realDocPath, "utf8"))),
|
|
1302
|
-
...entry,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
catch {
|
|
1306
|
-
/* 忽略已失效或不可读技能。 */
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
return { ...discovered, entries };
|
|
1310
|
-
}
|
|
1311
|
-
// 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
|
|
1312
|
-
root = definition ? definition.path : root;
|
|
1313
|
-
const rootStat = await lstatOrNull(resolve(root));
|
|
1314
|
-
if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
1315
|
-
return { exists: false, entries: [] };
|
|
1316
|
-
let items;
|
|
1317
|
-
try {
|
|
1318
|
-
items = await fs.readdir(root, { withFileTypes: true });
|
|
1319
|
-
}
|
|
1320
|
-
catch {
|
|
1321
|
-
return { exists: false, entries: [] };
|
|
1322
|
-
}
|
|
1323
|
-
const byName = new Map();
|
|
1324
|
-
const rootReal = await resolvedPath(root);
|
|
1325
|
-
for (const it of items) {
|
|
1326
|
-
try {
|
|
1327
|
-
if (it.isSymbolicLink())
|
|
1328
|
-
continue;
|
|
1329
|
-
if (it.isDirectory() && entryPath(root, it.name) !== null) {
|
|
1330
|
-
const docPath = join(root, it.name, "SKILL.md");
|
|
1331
|
-
const st = await lstatOrNull(docPath);
|
|
1332
|
-
if (!st ||
|
|
1333
|
-
!st.isFile() ||
|
|
1334
|
-
st.isSymbolicLink() ||
|
|
1335
|
-
!(await isInsideResolvedRoot(rootReal, docPath)))
|
|
1336
|
-
continue;
|
|
1337
|
-
const doc = options.metadataOnly
|
|
1338
|
-
? { map: {}, hasFrontmatter: false }
|
|
1339
|
-
: parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
1340
|
-
byName.set(it.name, {
|
|
1341
|
-
...entryOf(it.name, "bundle", docPath, doc),
|
|
1342
|
-
entryPath: join(root, it.name),
|
|
1343
|
-
realDocPath: await fs.realpath(docPath),
|
|
1344
|
-
realEntryPath: await fs.realpath(join(root, it.name)),
|
|
1345
|
-
linked: false,
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
else if (it.isFile() &&
|
|
1349
|
-
it.name.toLowerCase().endsWith(".md") &&
|
|
1350
|
-
it.name.toLowerCase() !== "skill.md" &&
|
|
1351
|
-
entryPath(root, it.name.slice(0, -3)) !== null) {
|
|
1352
|
-
const skillName = it.name.slice(0, -3);
|
|
1353
|
-
if (byName.has(skillName))
|
|
1354
|
-
continue;
|
|
1355
|
-
const docPath = join(root, it.name);
|
|
1356
|
-
if (!(await isInsideResolvedRoot(rootReal, docPath)))
|
|
1357
|
-
continue;
|
|
1358
|
-
const doc = options.metadataOnly
|
|
1359
|
-
? { map: {}, hasFrontmatter: false }
|
|
1360
|
-
: parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
1361
|
-
const realDocPath = await fs.realpath(docPath);
|
|
1362
|
-
byName.set(skillName, {
|
|
1363
|
-
...entryOf(skillName, "flat", docPath, doc),
|
|
1364
|
-
entryPath: docPath,
|
|
1365
|
-
realDocPath,
|
|
1366
|
-
realEntryPath: realDocPath,
|
|
1367
|
-
linked: false,
|
|
1368
|
-
});
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
catch {
|
|
1372
|
-
/* 跳过不可读条目 */
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
const entries = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1376
|
-
return { exists: true, entries };
|
|
1377
|
-
}
|
|
1378
417
|
/** 同一真实技能优先归属直接 SSOT;同类条目再按来源 rank 决胜。 */
|
|
1379
|
-
async function scanDeduplicatedRoots(roots = userRoots(), options = {}) {
|
|
1380
|
-
const results = await Promise.all(roots.map(async (root) => [
|
|
1381
|
-
root,
|
|
1382
|
-
await scanEntries(root, options),
|
|
1383
|
-
]));
|
|
1384
|
-
const scans = new Map();
|
|
1385
|
-
const groups = new Map();
|
|
1386
|
-
for (const [root, scan] of results) {
|
|
1387
|
-
scans.set(root.key, scan);
|
|
1388
|
-
for (const entry of scan.entries) {
|
|
1389
|
-
const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
|
|
1390
|
-
const group = groups.get(identity) || [];
|
|
1391
|
-
group.push({ root, entry });
|
|
1392
|
-
groups.set(identity, group);
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
const winners = new Set();
|
|
1396
|
-
for (const group of groups.values()) {
|
|
1397
|
-
group.sort((left, right) => Number(left.entry.linked) - Number(right.entry.linked) ||
|
|
1398
|
-
left.root.rank - right.root.rank);
|
|
1399
|
-
const winner = group[0];
|
|
1400
|
-
winner.entry.providerRank = Math.min(...group.map((item) => item.root.rank));
|
|
1401
|
-
winner.entry.policyAliases = group.map((item) => ({
|
|
1402
|
-
rootKey: item.root.key,
|
|
1403
|
-
name: item.entry.name,
|
|
1404
|
-
}));
|
|
1405
|
-
winners.add(winner.entry);
|
|
1406
|
-
}
|
|
1407
|
-
for (const scan of scans.values()) {
|
|
1408
|
-
scan.entries = scan.entries.filter((entry) => winners.has(entry));
|
|
1409
|
-
}
|
|
1410
|
-
return scans;
|
|
1411
|
-
}
|
|
1412
418
|
/** 详情与列表共享路径去重,扫描定位信息时不读取无关技能正文。 */
|
|
1413
419
|
async function visibleEntryForRoot(root, name) {
|
|
1414
420
|
if (!validDiscoveryName(name))
|
|
@@ -1421,195 +427,12 @@ async function visibleEntryForRoot(root, name) {
|
|
|
1421
427
|
return (scans.get(root.key)?.entries.find((entry) => entry.name === name) || null);
|
|
1422
428
|
}
|
|
1423
429
|
// ── Manager 本地策略(外部源只读,启停状态写入 DSH_HOME)────────────────────
|
|
1424
|
-
function defaultManagerState() {
|
|
1425
|
-
const sources = Object.create(null);
|
|
1426
|
-
const disabledSkills = Object.create(null);
|
|
1427
|
-
const enabledSkills = Object.create(null);
|
|
1428
|
-
for (const root of userRoots()) {
|
|
1429
|
-
// 默认来源(dsh / hub)没有来源开关,所以不落进 sources 表 —— 它们永远「读取」。
|
|
1430
|
-
// 旧版本可能往这里写过 hub 的 false,normalizeManagerState 会顺手丢掉。
|
|
1431
|
-
if (!isDefaultSkillSource(root.key))
|
|
1432
|
-
sources[root.key] = true;
|
|
1433
|
-
disabledSkills[root.key] = [];
|
|
1434
|
-
enabledSkills[root.key] = [];
|
|
1435
|
-
}
|
|
1436
|
-
return {
|
|
1437
|
-
version: 1,
|
|
1438
|
-
sources,
|
|
1439
|
-
disabledSkills,
|
|
1440
|
-
enabledSkills,
|
|
1441
|
-
customRoots: [],
|
|
1442
|
-
// 同名技能「首选来源」:技能名 → 来源 key。缺键 = 按来源 rank 取最高优先级者。
|
|
1443
|
-
preferredSkills: Object.create(null),
|
|
1444
|
-
// 用户从技能页「移除」的来源:插件**不再读取**这些目录(技能不出现在列表里,
|
|
1445
|
-
// 也不参与 provider 候选)。与「停用来源」不同——停用仍列出技能、只是不可调用。
|
|
1446
|
-
// 源文件一个字节都不动;清空这个数组即可恢复。
|
|
1447
|
-
removedSources: [],
|
|
1448
|
-
};
|
|
1449
|
-
}
|
|
1450
|
-
function validStateSkillName(name) {
|
|
1451
|
-
return validDiscoveryName(name);
|
|
1452
|
-
}
|
|
1453
430
|
/**
|
|
1454
431
|
* 首选表的键是技能**声明名**(不是文件名)——声明名允许大小写与空格(此时技能本身带
|
|
1455
432
|
* name.invalid 诊断,但仍可被选择)。这里只做「能当 JSON 键、不像路径」的宽松校验,
|
|
1456
433
|
* 避免一个脏键把整份状态文件判为非法(那会让全部技能 fail-closed 停用)。
|
|
1457
434
|
*/
|
|
1458
|
-
function validPreferredSkillName(name) {
|
|
1459
|
-
return (typeof name === "string" &&
|
|
1460
|
-
name.length > 0 &&
|
|
1461
|
-
name.length <= MAX_ENTRY_NAME_LENGTH &&
|
|
1462
|
-
!name.includes("\0") &&
|
|
1463
|
-
!name.includes("/") &&
|
|
1464
|
-
!name.includes("\\"));
|
|
1465
|
-
}
|
|
1466
435
|
/** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
|
|
1467
|
-
function failClosedManagerState() {
|
|
1468
|
-
const state = defaultManagerState();
|
|
1469
|
-
for (const key of Object.keys(state.sources))
|
|
1470
|
-
state.sources[key] = false;
|
|
1471
|
-
return state;
|
|
1472
|
-
}
|
|
1473
|
-
function validPolicyLists(value, allowMissing = false) {
|
|
1474
|
-
if (value === undefined && allowMissing)
|
|
1475
|
-
return true;
|
|
1476
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1477
|
-
return false;
|
|
1478
|
-
for (const [key, list] of Object.entries(value)) {
|
|
1479
|
-
if (!userRoots().some((root) => root.key === key) &&
|
|
1480
|
-
!PROJECT_ROOT_KEY_RE.test(key))
|
|
1481
|
-
continue;
|
|
1482
|
-
if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
|
|
1483
|
-
return false;
|
|
1484
|
-
}
|
|
1485
|
-
return true;
|
|
1486
|
-
}
|
|
1487
|
-
function validManagerStateDocument(value) {
|
|
1488
|
-
if (!value ||
|
|
1489
|
-
typeof value !== "object" ||
|
|
1490
|
-
Array.isArray(value) ||
|
|
1491
|
-
value.version !== 1)
|
|
1492
|
-
return false;
|
|
1493
|
-
if (!value.sources ||
|
|
1494
|
-
typeof value.sources !== "object" ||
|
|
1495
|
-
Array.isArray(value.sources))
|
|
1496
|
-
return false;
|
|
1497
|
-
if (!validPolicyLists(value.disabledSkills) ||
|
|
1498
|
-
!validPolicyLists(value.enabledSkills, true))
|
|
1499
|
-
return false;
|
|
1500
|
-
// 自定义来源列表:可选字段;容器必须是数组(条目级合法性由 normalize 逐条裁剪)。
|
|
1501
|
-
if (value.customRoots !== undefined && !Array.isArray(value.customRoots))
|
|
1502
|
-
return false;
|
|
1503
|
-
// 已移除的来源:可选字段;数组里的每一项必须能当来源 key 用。
|
|
1504
|
-
if (value.removedSources !== undefined) {
|
|
1505
|
-
if (!Array.isArray(value.removedSources) ||
|
|
1506
|
-
value.removedSources.some((key) => typeof key !== "string" || key.length === 0 || key.length > 128))
|
|
1507
|
-
return false;
|
|
1508
|
-
}
|
|
1509
|
-
// 同名首选表:可选字段;只校验容器与值类型,键值逐条在 normalize 里裁剪(见 validPreferredSkillName)。
|
|
1510
|
-
if (value.preferredSkills !== undefined) {
|
|
1511
|
-
if (!value.preferredSkills ||
|
|
1512
|
-
typeof value.preferredSkills !== "object" ||
|
|
1513
|
-
Array.isArray(value.preferredSkills))
|
|
1514
|
-
return false;
|
|
1515
|
-
for (const key of Object.values(value.preferredSkills))
|
|
1516
|
-
if (typeof key !== "string")
|
|
1517
|
-
return false;
|
|
1518
|
-
}
|
|
1519
|
-
for (const root of userRoots()) {
|
|
1520
|
-
if (isDefaultSkillSource(root.key))
|
|
1521
|
-
continue;
|
|
1522
|
-
if (typeof value.sources[root.key] !== "boolean")
|
|
1523
|
-
return false;
|
|
1524
|
-
const list = value.disabledSkills[root.key];
|
|
1525
|
-
if (!Array.isArray(list) || list.some((name) => !validStateSkillName(name)))
|
|
1526
|
-
return false;
|
|
1527
|
-
}
|
|
1528
|
-
const enabledSkills = value.enabledSkills || {};
|
|
1529
|
-
for (const key of new Set([
|
|
1530
|
-
...Object.keys(value.disabledSkills),
|
|
1531
|
-
...Object.keys(enabledSkills),
|
|
1532
|
-
])) {
|
|
1533
|
-
const disabled = new Set(value.disabledSkills[key] || []);
|
|
1534
|
-
if ((enabledSkills[key] || []).some((name) => disabled.has(name)))
|
|
1535
|
-
return false;
|
|
1536
|
-
}
|
|
1537
|
-
return true;
|
|
1538
|
-
}
|
|
1539
|
-
function normalizeManagerState(value) {
|
|
1540
|
-
const normalized = defaultManagerState();
|
|
1541
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1542
|
-
return normalized;
|
|
1543
|
-
// 自定义来源先归一,其 sources/policy 键才有归属。
|
|
1544
|
-
normalized.customRoots = normalizeCustomRoots(value.customRoots);
|
|
1545
|
-
const customKeys = new Set(normalized.customRoots.map((root) => root.key));
|
|
1546
|
-
for (const root of userRoots()) {
|
|
1547
|
-
// 默认来源不接收 sources 标志:旧版本写过的 `sources.hub = false` 在这里被丢弃,
|
|
1548
|
-
// 于是「曾经把 hub 停用掉」的用户升级后自动回到必须读取的状态(技能不丢,只是策略位作废)。
|
|
1549
|
-
if (!isDefaultSkillSource(root.key) &&
|
|
1550
|
-
value.sources &&
|
|
1551
|
-
typeof value.sources[root.key] === "boolean")
|
|
1552
|
-
normalized.sources[root.key] = value.sources[root.key];
|
|
1553
|
-
const list = value.disabledSkills && value.disabledSkills[root.key];
|
|
1554
|
-
if (Array.isArray(list))
|
|
1555
|
-
normalized.disabledSkills[root.key] = [
|
|
1556
|
-
...new Set(list.filter((name) => validStateSkillName(name))),
|
|
1557
|
-
].sort();
|
|
1558
|
-
const enabled = value.enabledSkills && value.enabledSkills[root.key];
|
|
1559
|
-
if (Array.isArray(enabled))
|
|
1560
|
-
normalized.enabledSkills[root.key] = [
|
|
1561
|
-
...new Set(enabled.filter((name) => validStateSkillName(name))),
|
|
1562
|
-
].sort();
|
|
1563
|
-
}
|
|
1564
|
-
if (value.sources && typeof value.sources === "object" && !Array.isArray(value.sources)) {
|
|
1565
|
-
for (const [key, flag] of Object.entries(value.sources)) {
|
|
1566
|
-
if (customKeys.has(key) && typeof flag === "boolean")
|
|
1567
|
-
normalized.sources[key] = flag;
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
for (const field of ["disabledSkills", "enabledSkills"]) {
|
|
1571
|
-
const source = value[field];
|
|
1572
|
-
if (source && typeof source === "object" && !Array.isArray(source)) {
|
|
1573
|
-
for (const [key, list] of Object.entries(source)) {
|
|
1574
|
-
if (!PROJECT_ROOT_KEY_RE.test(key) && !customKeys.has(key))
|
|
1575
|
-
continue;
|
|
1576
|
-
if (!Array.isArray(list))
|
|
1577
|
-
continue;
|
|
1578
|
-
normalized[field][key] = [
|
|
1579
|
-
...new Set(list.filter(validStateSkillName)),
|
|
1580
|
-
].sort();
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
// 同名首选表:键必须是技能声明名、值必须指向一个已知来源(用户级 / 自定义 / 项目级),
|
|
1585
|
-
// 否则丢弃该条(源目录被移除后残留的首选会自然失效)。
|
|
1586
|
-
const preferred = value.preferredSkills;
|
|
1587
|
-
if (preferred && typeof preferred === "object" && !Array.isArray(preferred)) {
|
|
1588
|
-
for (const [name, rootKey] of Object.entries(preferred)) {
|
|
1589
|
-
if (!validPreferredSkillName(name) || typeof rootKey !== "string")
|
|
1590
|
-
continue;
|
|
1591
|
-
if (!userRoots().some((root) => root.key === rootKey) &&
|
|
1592
|
-
!customKeys.has(rootKey) &&
|
|
1593
|
-
!PROJECT_ROOT_KEY_RE.test(rootKey))
|
|
1594
|
-
continue;
|
|
1595
|
-
normalized.preferredSkills[name] = rootKey;
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
// 已移除的来源:只保留「确实是已知来源 key」的条目,其余丢弃(避免脏键让整份状态非法)。
|
|
1599
|
-
{
|
|
1600
|
-
const raw = Array.isArray(value.removedSources) ? value.removedSources : [];
|
|
1601
|
-
const known = new Set([
|
|
1602
|
-
...userRoots().map((root) => root.key),
|
|
1603
|
-
...customKeys,
|
|
1604
|
-
]);
|
|
1605
|
-
normalized.removedSources = [
|
|
1606
|
-
...new Set(raw.filter((key) => typeof key === "string" &&
|
|
1607
|
-
known.has(key) &&
|
|
1608
|
-
!isDefaultSkillSource(key))),
|
|
1609
|
-
].sort();
|
|
1610
|
-
}
|
|
1611
|
-
return normalized;
|
|
1612
|
-
}
|
|
1613
436
|
/**
|
|
1614
437
|
* 参与扫描的来源 = 全部来源 − 用户已移除的。
|
|
1615
438
|
*
|
|
@@ -1624,27 +447,6 @@ export function activeUserRoots(stateValue) {
|
|
|
1624
447
|
return userRoots().filter((root) => !removed.has(root.key));
|
|
1625
448
|
}
|
|
1626
449
|
/** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
|
|
1627
|
-
function normalizeCustomRoots(value) {
|
|
1628
|
-
if (!Array.isArray(value))
|
|
1629
|
-
return [];
|
|
1630
|
-
const out = [];
|
|
1631
|
-
const seen = new Set();
|
|
1632
|
-
for (const item of value) {
|
|
1633
|
-
if (!item || typeof item !== "object")
|
|
1634
|
-
continue;
|
|
1635
|
-
const path = typeof item.path === "string" ? item.path : "";
|
|
1636
|
-
const key = typeof item.key === "string" ? item.key : "";
|
|
1637
|
-
if (!isAbsolute(path) || !CUSTOM_ROOT_KEY_RE.test(key))
|
|
1638
|
-
continue;
|
|
1639
|
-
if (key !== customRootKey(path) || seen.has(key))
|
|
1640
|
-
continue;
|
|
1641
|
-
seen.add(key);
|
|
1642
|
-
out.push(typeof item.label === "string" && item.label.trim()
|
|
1643
|
-
? { key, path, label: item.label.trim().slice(0, 64) }
|
|
1644
|
-
: { key, path });
|
|
1645
|
-
}
|
|
1646
|
-
return out;
|
|
1647
|
-
}
|
|
1648
450
|
/**
|
|
1649
451
|
* 读状态文件。
|
|
1650
452
|
*
|
|
@@ -1652,118 +454,11 @@ function normalizeCustomRoots(value) {
|
|
|
1652
454
|
* 校验器要求每个 `userRoots()` 来源都在 `sources` 里有布尔值、在 `disabledSkills` 里有数组,
|
|
1653
455
|
* 而来源列表会随版本增加(v0.4 加了 `hub` 来源)。于是**版本升级本身**就会把一份完好的旧
|
|
1654
456
|
* 状态文件判成「非法」,紧接着 fail-closed:所有来源停用、写入锁定,Skills 页弹
|
|
1655
|
-
* 「状态文件不可读,已拒绝覆盖」。文件其实一个字节都没坏。
|
|
1656
|
-
*
|
|
1657
|
-
* 归一化会把缺失的键补成默认值(新来源默认启用),所以「我们后来加过键」与「文件真的坏了」
|
|
1658
|
-
* 由此区分开:前者修复后通过,后者(不是对象、version 不是 1、字段类型不对)仍然拒绝。
|
|
1659
|
-
*/
|
|
1660
|
-
export async function readManagerState() {
|
|
1661
|
-
try {
|
|
1662
|
-
const raw = await fs.readFile(managerStatePath(), "utf8");
|
|
1663
|
-
const parsed = JSON.parse(raw);
|
|
1664
|
-
// 版本号在归一化时不会被保留(恒为 1),所以要在解析结果上直接判:
|
|
1665
|
-
// 缺失按 1 处理(更早的文档没有这个字段),存在但不是 1 才判非法。
|
|
1666
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1667
|
-
&& parsed.version !== undefined && parsed.version !== 1)
|
|
1668
|
-
throw codedError("invalid manager state schema", "error.state.invalid");
|
|
1669
|
-
// 结构守卫:字段**可以缺**(旧文档),但**一旦存在就必须形状正确**。
|
|
1670
|
-
// 少了这道守卫,归一化会把「类型写错」当成「键缺失」一起补默认值——于是 `sources: []`
|
|
1671
|
-
// 或 `disabledSkills: { dsh: "x" }` 这种真损坏反而被自愈放行,静默丢掉文件里的策略。
|
|
1672
|
-
// 契约:**缺键自愈(我们后来加过的东西),类型错必须报错。**
|
|
1673
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1674
|
-
for (const field of ["sources", "disabledSkills", "enabledSkills"]) {
|
|
1675
|
-
const value = parsed[field];
|
|
1676
|
-
if (value === undefined)
|
|
1677
|
-
continue;
|
|
1678
|
-
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1679
|
-
throw codedError("invalid manager state schema", "error.state.invalid");
|
|
1680
|
-
// 策略表的每个桶必须是数组(条目级合法性仍由 normalize 逐条裁剪)。
|
|
1681
|
-
if (field === "disabledSkills" || field === "enabledSkills") {
|
|
1682
|
-
for (const bucket of Object.values(value))
|
|
1683
|
-
if (!Array.isArray(bucket))
|
|
1684
|
-
throw codedError("invalid manager state schema", "error.state.invalid");
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
const normalized = normalizeManagerState(parsed);
|
|
1689
|
-
if (!validManagerStateDocument(normalized))
|
|
1690
|
-
throw codedError("invalid manager state schema", "error.state.invalid");
|
|
1691
|
-
return {
|
|
1692
|
-
state: normalized,
|
|
1693
|
-
warning: null,
|
|
1694
|
-
writable: true,
|
|
1695
|
-
};
|
|
1696
|
-
}
|
|
1697
|
-
catch (error) {
|
|
1698
|
-
// 系统异常按可选 code 读取,保持既有 ENOENT 判定。
|
|
1699
|
-
if (error && error.code === "ENOENT")
|
|
1700
|
-
return { state: defaultManagerState(), warning: null, writable: true };
|
|
1701
|
-
return {
|
|
1702
|
-
state: failClosedManagerState(),
|
|
1703
|
-
writable: false,
|
|
1704
|
-
warning: {
|
|
1705
|
-
code: "warning.state.invalid",
|
|
1706
|
-
params: { path: managerStatePath() },
|
|
1707
|
-
error: `技能管理器状态文件不可读,所有技能已安全停用且状态写入已锁定: ${managerStatePath()}`,
|
|
1708
|
-
},
|
|
1709
|
-
};
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
async function writeManagerState(value) {
|
|
1713
|
-
await fs.mkdir(managerHomePath(), { recursive: true });
|
|
1714
|
-
await writeFileAtomically(managerStatePath(), `${JSON.stringify(normalizeManagerState(value), null, 2)}\n`);
|
|
1715
|
-
}
|
|
1716
|
-
function managerSkillOverride(policy, rootKey, name, policyAliases = []) {
|
|
1717
|
-
if ((policy.enabledSkills[rootKey] || []).includes(name))
|
|
1718
|
-
return true;
|
|
1719
|
-
if ((policy.disabledSkills[rootKey] || []).includes(name))
|
|
1720
|
-
return false;
|
|
1721
|
-
let inheritedEnable = false;
|
|
1722
|
-
for (const alias of policyAliases) {
|
|
1723
|
-
if (alias.rootKey === rootKey && alias.name === name)
|
|
1724
|
-
continue;
|
|
1725
|
-
if ((policy.disabledSkills[alias.rootKey] || []).includes(alias.name))
|
|
1726
|
-
return false;
|
|
1727
|
-
if ((policy.enabledSkills[alias.rootKey] || []).includes(alias.name))
|
|
1728
|
-
inheritedEnable = true;
|
|
1729
|
-
}
|
|
1730
|
-
if (inheritedEnable)
|
|
1731
|
-
return true;
|
|
1732
|
-
return undefined;
|
|
1733
|
-
}
|
|
1734
|
-
function effectiveSkillPolicy(policyResult, root, entry) {
|
|
1735
|
-
const override = managerSkillOverride(policyResult.state, root.key, entry.name, entry.policyAliases);
|
|
1736
|
-
const sourceEnabled = isDefaultSkillSource(root) ||
|
|
1737
|
-
root.scope === "project" ||
|
|
1738
|
-
policyResult.state.sources[root.key] !== false;
|
|
1739
|
-
if (policyResult.writable === false || !sourceEnabled || override === false) {
|
|
1740
|
-
return {
|
|
1741
|
-
override,
|
|
1742
|
-
sourceEnabled,
|
|
1743
|
-
modelInvocable: false,
|
|
1744
|
-
userInvocable: false,
|
|
1745
|
-
enabled: false,
|
|
1746
|
-
};
|
|
1747
|
-
}
|
|
1748
|
-
if (override === true) {
|
|
1749
|
-
return {
|
|
1750
|
-
override,
|
|
1751
|
-
sourceEnabled,
|
|
1752
|
-
modelInvocable: true,
|
|
1753
|
-
userInvocable: true,
|
|
1754
|
-
enabled: true,
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
|
-
const modelInvocable = entry.invocationPolicyValid && entry.modelInvocable;
|
|
1758
|
-
const userInvocable = entry.invocationPolicyValid && entry.userInvocable;
|
|
1759
|
-
return {
|
|
1760
|
-
override,
|
|
1761
|
-
sourceEnabled,
|
|
1762
|
-
modelInvocable,
|
|
1763
|
-
userInvocable,
|
|
1764
|
-
enabled: modelInvocable && userInvocable,
|
|
1765
|
-
};
|
|
1766
|
-
}
|
|
457
|
+
* 「状态文件不可读,已拒绝覆盖」。文件其实一个字节都没坏。
|
|
458
|
+
*
|
|
459
|
+
* 归一化会把缺失的键补成默认值(新来源默认启用),所以「我们后来加过键」与「文件真的坏了」
|
|
460
|
+
* 由此区分开:前者修复后通过,后者(不是对象、version 不是 1、字段类型不对)仍然拒绝。
|
|
461
|
+
*/
|
|
1767
462
|
function invalidManagerStateWrite() {
|
|
1768
463
|
return {
|
|
1769
464
|
ok: false,
|
|
@@ -2019,37 +714,28 @@ export async function setPreferredSkill(root, name, preferred, log) {
|
|
|
2019
714
|
log(preferred === false ? "skill-unprefer" : "skill-prefer", preferred === false
|
|
2020
715
|
? `取消同名首选 ${canonicalName}`
|
|
2021
716
|
: `同名首选 ${canonicalName} → ${definition.key}`);
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
value.id !== id ||
|
|
2045
|
-
typeof value.name !== "string" ||
|
|
2046
|
-
!Array.isArray(value.entries))
|
|
2047
|
-
return null;
|
|
2048
|
-
return value;
|
|
2049
|
-
}
|
|
2050
|
-
catch {
|
|
2051
|
-
return null;
|
|
2052
|
-
}
|
|
717
|
+
// 首选**可以**指向停用来源:这个写操作只记录一次选择,不启停任何东西,所以照旧写成功。
|
|
718
|
+
// 但「记录成功」不等于「已经生效」—— 停用不参与选赢家、且停用赢家仍要挡住低优先级副本
|
|
719
|
+
// (见 listProviderCandidates),于是首选来源自己停用时,同名两份**都不生效**。
|
|
720
|
+
// 这里如实回话:调用方(含模型,`skill-prefer` 是模型可调的)不能以为「已改为生效」。
|
|
721
|
+
// 不返回 ok:false —— 那会让界面的「启用这个」(prefer 之后紧跟 enable)在 enable 之前就中断。
|
|
722
|
+
const effective = effectiveSkillPolicy(current, definition, {
|
|
723
|
+
...summary,
|
|
724
|
+
...resolved,
|
|
725
|
+
});
|
|
726
|
+
const base = {
|
|
727
|
+
name: canonicalName,
|
|
728
|
+
root: preferred === false ? null : definition.key,
|
|
729
|
+
};
|
|
730
|
+
if (preferred === false || effective.enabled)
|
|
731
|
+
return base;
|
|
732
|
+
return {
|
|
733
|
+
...base,
|
|
734
|
+
effective: false,
|
|
735
|
+
warning: `已记录同名首选,但它不会生效:${canonicalName}(${definition.label || definition.key})` +
|
|
736
|
+
`当前处于停用状态,而它仍然挡住同名的其他副本 —— 两份都不生效。` +
|
|
737
|
+
`启用其中一份即可(启用这份,或改用另一份)。`,
|
|
738
|
+
};
|
|
2053
739
|
}
|
|
2054
740
|
/**
|
|
2055
741
|
* Windows Defender / 索引器可能持续占用刚写入的 stage 目录,导致容器目录 rename
|
|
@@ -2101,52 +787,6 @@ async function publishTrashStage(stage, finalPath, metadata, renameOptions = {})
|
|
|
2101
787
|
* 所以这里必须按 scope 分岔:早先只特判 `dsh`,hub 会被记成项目级、`projectRoot`
|
|
2102
788
|
* 为 undefined,恢复时被判「原项目当前不在活动工作区中」—— 删除进去就再也拿不回来。
|
|
2103
789
|
*/
|
|
2104
|
-
function trashRootMetadata(definition) {
|
|
2105
|
-
if (definition.scope !== "project")
|
|
2106
|
-
return { key: definition.key, scope: "user", label: definition.label };
|
|
2107
|
-
return {
|
|
2108
|
-
key: definition.key,
|
|
2109
|
-
scope: "project",
|
|
2110
|
-
kind: "project-dsh",
|
|
2111
|
-
projectRoot: definition.projectRoot,
|
|
2112
|
-
projectName: definition.projectName,
|
|
2113
|
-
label: definition.label,
|
|
2114
|
-
};
|
|
2115
|
-
}
|
|
2116
|
-
async function restoreRootDefinition(metadata, options = {}) {
|
|
2117
|
-
// version 1 entries predate scoped Trash and always belong to $DSH_HOME/skills.
|
|
2118
|
-
if (!metadata.root)
|
|
2119
|
-
return rootByKey("dsh");
|
|
2120
|
-
// 用户级来源按 key 重新解析:路径来自 userRoots(),不信元数据里记住的 path
|
|
2121
|
-
// (DSH_HOME 换过之后,旧路径可能已经不在读取范围内了)。
|
|
2122
|
-
if (metadata.root.scope === "user")
|
|
2123
|
-
return rootByKey(metadata.root.key);
|
|
2124
|
-
if (metadata.root.scope !== "project" ||
|
|
2125
|
-
metadata.root.kind !== "project-dsh" ||
|
|
2126
|
-
typeof metadata.root.key !== "string" ||
|
|
2127
|
-
typeof metadata.root.projectRoot !== "string" ||
|
|
2128
|
-
!isAbsolute(metadata.root.projectRoot))
|
|
2129
|
-
return {
|
|
2130
|
-
ok: false,
|
|
2131
|
-
error: `回收站条目来源非法: ${metadata.id}`,
|
|
2132
|
-
code: "error.trash.invalid",
|
|
2133
|
-
params: { id: metadata.id },
|
|
2134
|
-
};
|
|
2135
|
-
const roots = await projectRoots(options.projectCwds);
|
|
2136
|
-
const normalizedProjectRoot = pathIdentity(metadata.root.projectRoot);
|
|
2137
|
-
const root = roots.find((item) => item.key === metadata.root.key &&
|
|
2138
|
-
item.kind === "project-dsh" &&
|
|
2139
|
-
pathIdentity(item.projectRoot) === normalizedProjectRoot);
|
|
2140
|
-
if (!root) {
|
|
2141
|
-
return {
|
|
2142
|
-
ok: false,
|
|
2143
|
-
error: `原项目当前不在活动工作区中,无法恢复: ${metadata.root.projectRoot}`,
|
|
2144
|
-
code: "error.trash.projectUnavailable",
|
|
2145
|
-
params: { path: metadata.root.projectRoot },
|
|
2146
|
-
};
|
|
2147
|
-
}
|
|
2148
|
-
return checkedWritableRootDefinition(root);
|
|
2149
|
-
}
|
|
2150
790
|
/** 把项目级 DSH 根中的单个技能移入 manager-owned 回收站。 */
|
|
2151
791
|
export async function deleteSkill(root, name, log, options = {}) {
|
|
2152
792
|
const definition = await checkedWritableRootDefinition(root);
|
|
@@ -2222,662 +862,45 @@ export async function deleteSkill(root, name, log, options = {}) {
|
|
|
2222
862
|
throw error;
|
|
2223
863
|
}
|
|
2224
864
|
}
|
|
2225
|
-
export async function listTrash() {
|
|
2226
|
-
let items;
|
|
2227
|
-
try {
|
|
2228
|
-
items = await fs.readdir(trashRootPath(), { withFileTypes: true });
|
|
2229
|
-
}
|
|
2230
|
-
catch {
|
|
2231
|
-
return [];
|
|
2232
|
-
}
|
|
2233
|
-
const result = [];
|
|
2234
|
-
for (const item of items) {
|
|
2235
|
-
if (!item.isDirectory() || item.name.startsWith("."))
|
|
2236
|
-
continue;
|
|
2237
|
-
const metadata = await readTrashMetadata(item.name);
|
|
2238
|
-
if (metadata)
|
|
2239
|
-
result.push(metadata);
|
|
2240
|
-
}
|
|
2241
|
-
return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
|
|
2242
|
-
}
|
|
2243
|
-
export async function restoreTrash(id, log, options = {}) {
|
|
2244
|
-
const metadata = await readTrashMetadata(id);
|
|
2245
|
-
if (!metadata)
|
|
2246
|
-
return {
|
|
2247
|
-
ok: false,
|
|
2248
|
-
error: `回收站条目不存在: ${id}`,
|
|
2249
|
-
code: "error.trash.notFound",
|
|
2250
|
-
params: { id },
|
|
2251
|
-
};
|
|
2252
|
-
const definition = await restoreRootDefinition(metadata, options);
|
|
2253
|
-
if (!definition || definition.ok === false)
|
|
2254
|
-
return definition || readonlyError("restore");
|
|
2255
|
-
const root = definition.path;
|
|
2256
|
-
const conflicts = await safeExistingEntryPaths(root, metadata.name);
|
|
2257
|
-
if (conflicts.length)
|
|
2258
|
-
return {
|
|
2259
|
-
ok: false,
|
|
2260
|
-
error: `无法恢复,同名技能已存在: ${metadata.name}`,
|
|
2261
|
-
code: "error.trash.conflict",
|
|
2262
|
-
params: { name: metadata.name },
|
|
2263
|
-
};
|
|
2264
|
-
await fs.mkdir(root, { recursive: true });
|
|
2265
|
-
const itemRoot = join(trashRootPath(), id);
|
|
2266
|
-
const moved = [];
|
|
2267
|
-
try {
|
|
2268
|
-
for (const fileName of metadata.entries) {
|
|
2269
|
-
const source = join(itemRoot, fileName);
|
|
2270
|
-
const destination = join(root, fileName);
|
|
2271
|
-
if (!isSameOrDescendant(itemRoot, source) ||
|
|
2272
|
-
!isSameOrDescendant(root, destination))
|
|
2273
|
-
throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
|
|
2274
|
-
await movePathWithFallback(source, destination, options.renameOptions);
|
|
2275
|
-
moved.push({ source, destination });
|
|
2276
|
-
}
|
|
2277
|
-
await fs.rm(itemRoot, { recursive: true, force: true });
|
|
2278
|
-
if (log)
|
|
2279
|
-
log("restore", `从回收站恢复 ${metadata.name} -> ${root}`);
|
|
2280
|
-
return { id, name: metadata.name, root: trashRootMetadata(definition) };
|
|
2281
|
-
}
|
|
2282
|
-
catch (error) {
|
|
2283
|
-
for (const item of moved.reverse())
|
|
2284
|
-
await movePathWithFallback(item.destination, item.source, options.renameOptions).catch(() => undefined);
|
|
2285
|
-
throw error;
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
export async function permanentlyDeleteTrash(id, log) {
|
|
2289
|
-
const metadata = await readTrashMetadata(id);
|
|
2290
|
-
if (!metadata)
|
|
2291
|
-
return {
|
|
2292
|
-
ok: false,
|
|
2293
|
-
error: `回收站条目不存在: ${id}`,
|
|
2294
|
-
code: "error.trash.notFound",
|
|
2295
|
-
params: { id },
|
|
2296
|
-
};
|
|
2297
|
-
await fs.rm(join(trashRootPath(), id), { recursive: true, force: true });
|
|
2298
|
-
if (log)
|
|
2299
|
-
log("trash-delete", `永久删除回收站条目 ${metadata.name} (${id})`);
|
|
2300
|
-
return { id, name: metadata.name };
|
|
2301
|
-
}
|
|
2302
865
|
// ── 导入 ────────────────────────────────────────────────────────────────────
|
|
2303
866
|
/** 分析来源:单 skill 目录 / 单 .md 文件 / 批量目录。 */
|
|
2304
|
-
async function analyzeSource(source) {
|
|
2305
|
-
let st;
|
|
2306
|
-
try {
|
|
2307
|
-
st = await fs.lstat(source);
|
|
2308
|
-
}
|
|
2309
|
-
catch {
|
|
2310
|
-
return {
|
|
2311
|
-
kind: "none",
|
|
2312
|
-
error: `路径不存在: ${source}`,
|
|
2313
|
-
code: "error.source.notFound",
|
|
2314
|
-
params: { path: source },
|
|
2315
|
-
};
|
|
2316
|
-
}
|
|
2317
|
-
if (st.isSymbolicLink())
|
|
2318
|
-
return {
|
|
2319
|
-
kind: "none",
|
|
2320
|
-
error: `不支持包含符号链接的 skill 来源: ${source}`,
|
|
2321
|
-
code: "error.source.symlink",
|
|
2322
|
-
params: { path: source },
|
|
2323
|
-
};
|
|
2324
|
-
if (st.isDirectory()) {
|
|
2325
|
-
const sk = join(source, "SKILL.md");
|
|
2326
|
-
const skSt = await lstatOrNull(sk);
|
|
2327
|
-
// 预检与实际导入口径一致:SKILL.md 本身是链接时直接拒绝,避免 dry-run 通过、正式导入才失败。
|
|
2328
|
-
if (skSt && skSt.isSymbolicLink())
|
|
2329
|
-
return {
|
|
2330
|
-
kind: "none",
|
|
2331
|
-
error: `不支持包含符号链接的 skill 来源: ${sk}`,
|
|
2332
|
-
code: "error.source.symlink",
|
|
2333
|
-
params: { path: sk },
|
|
2334
|
-
};
|
|
2335
|
-
if (skSt && skSt.isFile()) {
|
|
2336
|
-
return {
|
|
2337
|
-
kind: "single",
|
|
2338
|
-
rawName: basename(source),
|
|
2339
|
-
kebab: toKebab(basename(source)),
|
|
2340
|
-
source,
|
|
2341
|
-
isDir: true,
|
|
2342
|
-
skillFile: sk,
|
|
2343
|
-
};
|
|
2344
|
-
}
|
|
2345
|
-
return { kind: "batch", rawName: basename(source), source, isDir: true };
|
|
2346
|
-
}
|
|
2347
|
-
if (st.isFile() && source.toLowerCase().endsWith(".md")) {
|
|
2348
|
-
if (basename(source).toLowerCase() === "skill.md") {
|
|
2349
|
-
const parent = dirname(source);
|
|
2350
|
-
return {
|
|
2351
|
-
kind: "single",
|
|
2352
|
-
rawName: basename(parent),
|
|
2353
|
-
kebab: toKebab(basename(parent)),
|
|
2354
|
-
source: parent,
|
|
2355
|
-
isDir: true,
|
|
2356
|
-
skillFile: source,
|
|
2357
|
-
};
|
|
2358
|
-
}
|
|
2359
|
-
const rawName = basename(source).slice(0, -3);
|
|
2360
|
-
return {
|
|
2361
|
-
kind: "single",
|
|
2362
|
-
rawName,
|
|
2363
|
-
kebab: toKebab(rawName),
|
|
2364
|
-
source,
|
|
2365
|
-
isDir: false,
|
|
2366
|
-
skillFile: source,
|
|
2367
|
-
};
|
|
2368
|
-
}
|
|
2369
|
-
return {
|
|
2370
|
-
kind: "none",
|
|
2371
|
-
error: `无法识别的 skill 来源: ${source}`,
|
|
2372
|
-
code: "error.source.unrecognized",
|
|
2373
|
-
params: { path: source },
|
|
2374
|
-
};
|
|
2375
|
-
}
|
|
2376
|
-
async function collectCandidates(dir) {
|
|
2377
|
-
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
2378
|
-
const out = [];
|
|
2379
|
-
for (const it of items) {
|
|
2380
|
-
if (it.isSymbolicLink())
|
|
2381
|
-
throw codedError(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`, "error.source.symlink", { path: join(dir, it.name) });
|
|
2382
|
-
if (it.isDirectory()) {
|
|
2383
|
-
const sk = join(dir, it.name, "SKILL.md");
|
|
2384
|
-
// lstatOrNull 吞掉 IO 异常(返回 null 即跳过);symlink 必须抛出,不能被“跳过”逻辑掩盖。
|
|
2385
|
-
const st = await lstatOrNull(sk);
|
|
2386
|
-
if (st && st.isSymbolicLink())
|
|
2387
|
-
throw codedError(`不支持包含符号链接的 skill 来源: ${sk}`, "error.source.symlink", { path: sk });
|
|
2388
|
-
if (st && st.isFile()) {
|
|
2389
|
-
out.push({
|
|
2390
|
-
source: join(dir, it.name),
|
|
2391
|
-
kebab: toKebab(it.name),
|
|
2392
|
-
rawName: it.name,
|
|
2393
|
-
isDir: true,
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
else if (it.isFile() &&
|
|
2398
|
-
it.name.toLowerCase().endsWith(".md") &&
|
|
2399
|
-
it.name.toLowerCase() !== "skill.md") {
|
|
2400
|
-
out.push({
|
|
2401
|
-
source: join(dir, it.name),
|
|
2402
|
-
kebab: toKebab(it.name.slice(0, -3)),
|
|
2403
|
-
rawName: it.name.slice(0, -3),
|
|
2404
|
-
isDir: false,
|
|
2405
|
-
});
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
return out;
|
|
2409
|
-
}
|
|
2410
867
|
/** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
|
|
2411
|
-
async function assertNoSymbolicLinks(source) {
|
|
2412
|
-
const pending = [
|
|
2413
|
-
{ path: source, depth: 0 },
|
|
2414
|
-
];
|
|
2415
|
-
while (pending.length) {
|
|
2416
|
-
// while 条件保证栈非空,断言只为让类型收敛。
|
|
2417
|
-
const current = pending.pop();
|
|
2418
|
-
if (current.depth > MAX_SOURCE_DEPTH)
|
|
2419
|
-
throw codedError(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`, "error.source.tooDeep", { depth: MAX_SOURCE_DEPTH, path: source });
|
|
2420
|
-
const st = await fs.lstat(current.path);
|
|
2421
|
-
if (st.isSymbolicLink())
|
|
2422
|
-
throw codedError(`不支持包含符号链接的 skill 来源: ${current.path}`, "error.source.symlink", { path: current.path });
|
|
2423
|
-
if (!st.isDirectory())
|
|
2424
|
-
continue;
|
|
2425
|
-
const items = await fs.readdir(current.path, { withFileTypes: true });
|
|
2426
|
-
for (const item of items) {
|
|
2427
|
-
const path = join(current.path, item.name);
|
|
2428
|
-
if (item.isSymbolicLink())
|
|
2429
|
-
throw codedError(`不支持包含符号链接的 skill 来源: ${path}`, "error.source.symlink", { path });
|
|
2430
|
-
if (item.isDirectory())
|
|
2431
|
-
pending.push({ path, depth: current.depth + 1 });
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
function temporaryPath(target, kind) {
|
|
2436
|
-
return join(dirname(target), `.${basename(target)}.dssm-${kind}-${randomUUID()}`);
|
|
2437
|
-
}
|
|
2438
868
|
/** dry-run 预检执行与正式导入相同的符号链接/深度检查,预检失败即结论,不再进入覆盖确认。
|
|
2439
869
|
* 预检与实导之间来源被替换的竞态仍由实导阶段的复制后校验兜底。 */
|
|
2440
|
-
async function preflightCandidates(pending, conflicts, failed) {
|
|
2441
|
-
for (const group of [pending, conflicts]) {
|
|
2442
|
-
for (let i = group.length - 1; i >= 0; i--) {
|
|
2443
|
-
const candidate = group[i];
|
|
2444
|
-
try {
|
|
2445
|
-
await assertNoSymbolicLinks(candidate.source);
|
|
2446
|
-
}
|
|
2447
|
-
catch (error) {
|
|
2448
|
-
// 系统异常按可选 message 读取,保持既有失败明细文案。
|
|
2449
|
-
failed.push(attachCode({
|
|
2450
|
-
source: candidate.source,
|
|
2451
|
-
error: String(error && error.message ? error.message : error),
|
|
2452
|
-
}, error));
|
|
2453
|
-
group.splice(i, 1);
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
870
|
/** 先复制到同目录临时路径,复制失败时不触碰现有技能。 */
|
|
2459
|
-
async function copyToTemporary(source, target, isDir) {
|
|
2460
|
-
const temp = temporaryPath(target, "stage");
|
|
2461
|
-
try {
|
|
2462
|
-
await assertNoSymbolicLinks(source);
|
|
2463
|
-
if (isDir)
|
|
2464
|
-
await fs.cp(source, temp, { recursive: true, dereference: false });
|
|
2465
|
-
else
|
|
2466
|
-
await fs.copyFile(source, temp);
|
|
2467
|
-
await assertNoSymbolicLinks(temp);
|
|
2468
|
-
return temp;
|
|
2469
|
-
}
|
|
2470
|
-
catch (error) {
|
|
2471
|
-
await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
2472
|
-
throw error;
|
|
2473
|
-
}
|
|
2474
|
-
}
|
|
2475
871
|
/** 临时副本就绪后再替换;替换失败时尽力恢复旧条目。 */
|
|
2476
|
-
async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
2477
|
-
const stage = await copyToTemporary(source, dest, isDir);
|
|
2478
|
-
const backups = [];
|
|
2479
|
-
// 三处 rename 一律走 `renameWithRetry`:瞬时占用(Windows 杀软 / 索引器 / 资源管理器)返回的
|
|
2480
|
-
// EACCES / EBUSY / EPERM 正是它重试的那些码。此前只有 `:959` 的回滚用了带重试的版本,
|
|
2481
|
-
// 而**发布与它的补偿回滚用的是裸 rename** —— 发布因瞬时锁抛错时,紧随其后的回滚在同一刻、
|
|
2482
|
-
// 同一目录上大概率撞同一个句柄,于是留下"目标已空、真值还叫 backup"的半态(无自动恢复路径)。
|
|
2483
|
-
// 保护覆盖不均不是取舍,是漏。
|
|
2484
|
-
try {
|
|
2485
|
-
for (const path of existing) {
|
|
2486
|
-
const backup = temporaryPath(path, "backup");
|
|
2487
|
-
await renameWithRetry(path, backup);
|
|
2488
|
-
backups.push({ path, backup });
|
|
2489
|
-
}
|
|
2490
|
-
await renameWithRetry(stage, dest);
|
|
2491
|
-
}
|
|
2492
|
-
catch (error) {
|
|
2493
|
-
// 系统异常按可选 message 读取,保持既有回滚失败明细。
|
|
2494
|
-
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
2495
|
-
const rollbackFailures = [];
|
|
2496
|
-
for (const item of backups.reverse()) {
|
|
2497
|
-
try {
|
|
2498
|
-
await renameWithRetry(item.backup, item.path);
|
|
2499
|
-
}
|
|
2500
|
-
catch (rollbackError) {
|
|
2501
|
-
rollbackFailures.push(item.backup);
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
if (rollbackFailures.length) {
|
|
2505
|
-
const causeText = String(error && error.message ? error.message : error);
|
|
2506
|
-
throw codedError(`${causeText};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`, "error.import.rollbackFailed", { path: rollbackFailures.join("; "), error: causeText });
|
|
2507
|
-
}
|
|
2508
|
-
throw error;
|
|
2509
|
-
}
|
|
2510
|
-
const warnings = [];
|
|
2511
|
-
for (const item of backups) {
|
|
2512
|
-
try {
|
|
2513
|
-
await fs.rm(item.backup, { recursive: true, force: true });
|
|
2514
|
-
}
|
|
2515
|
-
catch (error) {
|
|
2516
|
-
// 系统异常按可选 message 读取,保持既有告警文案。
|
|
2517
|
-
warnings.push({
|
|
2518
|
-
code: "warning.backupUncleaned",
|
|
2519
|
-
params: {
|
|
2520
|
-
path: item.backup,
|
|
2521
|
-
error: String(error && error.message ? error.message : error),
|
|
2522
|
-
},
|
|
2523
|
-
error: `旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`,
|
|
2524
|
-
});
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
return warnings;
|
|
2528
|
-
}
|
|
2529
872
|
/**
|
|
2530
873
|
* 导入技能到目标根。
|
|
2531
874
|
* options: { conflict: 'skip'|'overwrite', dryRun: boolean }
|
|
2532
875
|
* 成功返回 { kind, imported, skipped, failed };失败返回 { ok:false, error }。
|
|
2533
876
|
*/
|
|
2534
|
-
export async function importSkill(source, log, options = {}) {
|
|
2535
|
-
const targetRoot = skillCreateRootPath();
|
|
2536
|
-
const conflict = options.conflict === "overwrite" ? "overwrite" : "skip";
|
|
2537
|
-
const dryRun = options.dryRun === true;
|
|
2538
|
-
const analysis = await analyzeSource(source);
|
|
2539
|
-
if (analysis.kind === "none")
|
|
2540
|
-
return {
|
|
2541
|
-
ok: false,
|
|
2542
|
-
error: analysis.error || "无法识别的 skill 来源",
|
|
2543
|
-
code: analysis.code || "error.source.unrecognized",
|
|
2544
|
-
params: analysis.params,
|
|
2545
|
-
};
|
|
2546
|
-
if (await pathsOverlap(analysis.source, targetRoot))
|
|
2547
|
-
return {
|
|
2548
|
-
ok: false,
|
|
2549
|
-
error: "导入来源不能与 DSH 技能目录相同、包含或位于其中",
|
|
2550
|
-
code: "error.import.overlap",
|
|
2551
|
-
};
|
|
2552
|
-
let candidates = [];
|
|
2553
|
-
if (analysis.kind === "single") {
|
|
2554
|
-
candidates = [
|
|
2555
|
-
{
|
|
2556
|
-
source: analysis.source,
|
|
2557
|
-
kebab: analysis.kebab,
|
|
2558
|
-
rawName: analysis.rawName,
|
|
2559
|
-
isDir: analysis.isDir,
|
|
2560
|
-
},
|
|
2561
|
-
];
|
|
2562
|
-
}
|
|
2563
|
-
else {
|
|
2564
|
-
try {
|
|
2565
|
-
candidates = await collectCandidates(source);
|
|
2566
|
-
}
|
|
2567
|
-
catch (error) {
|
|
2568
|
-
// 系统异常按可选 message 读取,保持既有失败明细文案。
|
|
2569
|
-
return attachCode({
|
|
2570
|
-
ok: false,
|
|
2571
|
-
error: String(error && error.message ? error.message : error),
|
|
2572
|
-
}, error);
|
|
2573
|
-
}
|
|
2574
|
-
if (candidates.length === 0)
|
|
2575
|
-
return {
|
|
2576
|
-
ok: false,
|
|
2577
|
-
error: `目录下未找到任何 skill 条目(需含 SKILL.md 的子目录或 .md 文件): ${source}`,
|
|
2578
|
-
code: "error.import.emptySource",
|
|
2579
|
-
params: { path: source },
|
|
2580
|
-
};
|
|
2581
|
-
}
|
|
2582
|
-
const pending = [];
|
|
2583
|
-
const conflicts = [];
|
|
2584
|
-
const failed = [];
|
|
2585
|
-
const imported = [];
|
|
2586
|
-
const skipped = [];
|
|
2587
|
-
const nameCount = new Map();
|
|
2588
|
-
for (const candidate of candidates) {
|
|
2589
|
-
if (candidate.kebab &&
|
|
2590
|
-
KEBAB_RE.test(candidate.kebab) &&
|
|
2591
|
-
entryPath(targetRoot, candidate.kebab) !== null)
|
|
2592
|
-
nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
|
|
2593
|
-
}
|
|
2594
|
-
function failureResult() {
|
|
2595
|
-
return {
|
|
2596
|
-
ok: false,
|
|
2597
|
-
// 聚合失败明细的原文;前端优先展示已翻译的 failed 明细,此处仅作兜底。
|
|
2598
|
-
error: failed.map((item) => item.error).join(";"),
|
|
2599
|
-
code: "error.import.failed",
|
|
2600
|
-
kind: analysis.kind,
|
|
2601
|
-
imported,
|
|
2602
|
-
skipped,
|
|
2603
|
-
failed,
|
|
2604
|
-
};
|
|
2605
|
-
}
|
|
2606
|
-
for (const c of candidates) {
|
|
2607
|
-
if (!c.kebab ||
|
|
2608
|
-
!KEBAB_RE.test(c.kebab) ||
|
|
2609
|
-
entryPath(targetRoot, c.kebab) === null) {
|
|
2610
|
-
failed.push({
|
|
2611
|
-
source: c.source,
|
|
2612
|
-
error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})`,
|
|
2613
|
-
code: "error.import.invalidName",
|
|
2614
|
-
params: { name: c.rawName || basename(c.source) },
|
|
2615
|
-
});
|
|
2616
|
-
continue;
|
|
2617
|
-
}
|
|
2618
|
-
// 上方计数循环已为该 kebab 记数(合法名称且目标可写),断言只为让类型收敛。
|
|
2619
|
-
if (nameCount.get(c.kebab) > 1) {
|
|
2620
|
-
failed.push({
|
|
2621
|
-
source: c.source,
|
|
2622
|
-
error: `批量来源中存在多个同名插件: ${c.kebab}`,
|
|
2623
|
-
code: "error.import.duplicateName",
|
|
2624
|
-
params: { name: c.kebab },
|
|
2625
|
-
});
|
|
2626
|
-
continue;
|
|
2627
|
-
}
|
|
2628
|
-
const dest = c.isDir
|
|
2629
|
-
? join(targetRoot, c.kebab)
|
|
2630
|
-
: join(targetRoot, `${c.kebab}.md`);
|
|
2631
|
-
const paths = [
|
|
2632
|
-
join(targetRoot, c.kebab),
|
|
2633
|
-
join(targetRoot, `${c.kebab}.md`),
|
|
2634
|
-
];
|
|
2635
|
-
const existing = [];
|
|
2636
|
-
for (const path of paths) {
|
|
2637
|
-
try {
|
|
2638
|
-
await fs.stat(path);
|
|
2639
|
-
existing.push(path);
|
|
2640
|
-
}
|
|
2641
|
-
catch { }
|
|
2642
|
-
}
|
|
2643
|
-
if (existing.length) {
|
|
2644
|
-
conflicts.push({
|
|
2645
|
-
name: c.kebab,
|
|
2646
|
-
source: c.source,
|
|
2647
|
-
isDir: c.isDir,
|
|
2648
|
-
paths: existing,
|
|
2649
|
-
});
|
|
2650
|
-
continue;
|
|
2651
|
-
}
|
|
2652
|
-
pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
|
|
2653
|
-
}
|
|
2654
|
-
if (pending.length === 0 && conflicts.length === 0)
|
|
2655
|
-
return failureResult();
|
|
2656
|
-
if (dryRun) {
|
|
2657
|
-
// 预检即结论:与正式导入同口径执行符号链接/深度检查,避免预检通过、确认覆盖后实导才失败。
|
|
2658
|
-
await preflightCandidates(pending, conflicts, failed);
|
|
2659
|
-
if (pending.length === 0 && conflicts.length === 0)
|
|
2660
|
-
return failureResult();
|
|
2661
|
-
return { kind: analysis.kind, pending, conflicts, failed };
|
|
2662
|
-
}
|
|
2663
|
-
if (pending.length > 0 ||
|
|
2664
|
-
(conflict === "overwrite" && conflicts.length > 0)) {
|
|
2665
|
-
await fs.mkdir(targetRoot, { recursive: true });
|
|
2666
|
-
}
|
|
2667
|
-
for (const p of pending) {
|
|
2668
|
-
try {
|
|
2669
|
-
const warnings = await replaceWithCopy(p.source, p.dest, p.isDir);
|
|
2670
|
-
imported.push({ name: p.name, overwritten: false, warnings });
|
|
2671
|
-
if (log)
|
|
2672
|
-
log("import", `导入 ${p.source} -> ${p.dest}`);
|
|
2673
|
-
}
|
|
2674
|
-
catch (e) {
|
|
2675
|
-
// 系统异常按可选 message 读取,保持既有失败明细文案。
|
|
2676
|
-
failed.push(attachCode({ source: p.source, error: String(e && e.message ? e.message : e) }, e));
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
if (conflict === "overwrite") {
|
|
2680
|
-
for (const c of conflicts) {
|
|
2681
|
-
try {
|
|
2682
|
-
const dest = c.isDir
|
|
2683
|
-
? join(targetRoot, c.name)
|
|
2684
|
-
: join(targetRoot, `${c.name}.md`);
|
|
2685
|
-
const warnings = await replaceWithCopy(c.source, dest, c.isDir, c.paths);
|
|
2686
|
-
imported.push({ name: c.name, overwritten: true, warnings });
|
|
2687
|
-
if (log)
|
|
2688
|
-
log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
|
|
2689
|
-
}
|
|
2690
|
-
catch (e) {
|
|
2691
|
-
// 系统异常按可选 message 读取,保持既有失败明细文案。
|
|
2692
|
-
failed.push(attachCode({ source: c.source, error: String(e && e.message ? e.message : e) }, e));
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
}
|
|
2696
|
-
else {
|
|
2697
|
-
for (const c of conflicts)
|
|
2698
|
-
skipped.push({ name: c.name, source: c.source });
|
|
2699
|
-
}
|
|
2700
|
-
if (failed.length && imported.length === 0)
|
|
2701
|
-
return failureResult();
|
|
2702
|
-
return { kind: analysis.kind, imported, skipped, failed };
|
|
2703
|
-
}
|
|
2704
877
|
// ── 浏览器上传导入 ──────────────────────────────────────────────────────────
|
|
878
|
+
/**
|
|
879
|
+
* 一个上传路径段会不会被文件系统**改写**成别的东西。
|
|
880
|
+
*
|
|
881
|
+
* 为什么必须单判这一条(2026-09-30 审查 P2-17,实为路径逃逸而非口径分叉):
|
|
882
|
+
* 下面的 `part === ".."` 只挡**恰好**是 `..` 的段,而 Windows 的路径归一化会把段尾的
|
|
883
|
+
* **点与空格**静默裁掉 —— `.. `(`..` 加一个空格)在 `CreateFileW` / `CreateDirectoryW`
|
|
884
|
+
* 眼里就是 `..`。于是 `join(contentRoot, ".. ", "x")` 看着在根内(`isSameOrDescendant`
|
|
885
|
+
* 也判在根内:它按字符串比,`".. "` 不等于 `".."`,`relative` 得到的 `".. /x"` 也不以
|
|
886
|
+
* `"../"` 开头),实际会写到**暂存目录之外**。同一条判据顺带覆盖 `...` / `. ` /
|
|
887
|
+
* `foo.`(最后一个不逃逸,但会让磁盘上的名字与界面显示的名字不一致 —— `paths.ts` 的
|
|
888
|
+
* `trailing-dot-or-space` 就是为这个存在的)。
|
|
889
|
+
*
|
|
890
|
+
* 只挡"会被改写"的形态,**不挡** `.gitignore` / `.DS_Store` 这类正常点文件:技能包带它们
|
|
891
|
+
* 是常态(macOS 解压出来的包必带 `.DS_Store`),一律拒绝会让正常技能导不进来。
|
|
892
|
+
* 注意 `../imports/upload.ts` 的 `normalizeEntryPath` 走的是更宽的一条(拒绝**所有**
|
|
893
|
+
* 点开头的段)—— 那里是历史口径,方向更保守,两边都安全,不必强行统一。
|
|
894
|
+
*/
|
|
2705
895
|
/**
|
|
2706
896
|
* 校验浏览器或 ZIP 提供的相对路径。上传内容始终写成普通文件,不解释 ZIP 的链接元数据。
|
|
2707
897
|
* 这样既不依赖浏览器泄露本机绝对路径,也不会让归档跨出管理器暂存目录。
|
|
2708
898
|
*/
|
|
2709
|
-
|
|
2710
|
-
const raw = String(input == null ? "" : input).replace(/\\/g, "/");
|
|
2711
|
-
if (!raw ||
|
|
2712
|
-
raw.length > MAX_UPLOAD_PATH_LENGTH ||
|
|
2713
|
-
raw.includes("\0") ||
|
|
2714
|
-
raw.startsWith("/") ||
|
|
2715
|
-
/^[A-Za-z]:/.test(raw) ||
|
|
2716
|
-
raw.startsWith("//")) {
|
|
2717
|
-
throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
|
|
2718
|
-
path: raw,
|
|
2719
|
-
});
|
|
2720
|
-
}
|
|
2721
|
-
const directory = raw.endsWith("/");
|
|
2722
|
-
const parts = raw
|
|
2723
|
-
.split("/")
|
|
2724
|
-
.filter((part, index, all) => directory && index === all.length - 1 ? false : true);
|
|
2725
|
-
if (!parts.length ||
|
|
2726
|
-
parts.length > MAX_SOURCE_DEPTH ||
|
|
2727
|
-
parts.some((part) => !part ||
|
|
2728
|
-
part === "." ||
|
|
2729
|
-
part === ".." ||
|
|
2730
|
-
part.length > MAX_ENTRY_NAME_LENGTH ||
|
|
2731
|
-
WINDOWS_DEVICE_NAME_RE.test(part))) {
|
|
2732
|
-
throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
|
|
2733
|
-
path: raw,
|
|
2734
|
-
});
|
|
2735
|
-
}
|
|
2736
|
-
return { path: parts.join("/"), directory };
|
|
2737
|
-
}
|
|
2738
|
-
function decodeUploadBase64(value, maxBytes, code = "error.upload.tooLarge") {
|
|
2739
|
-
const raw = String(value == null ? "" : value);
|
|
2740
|
-
const padding = raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0;
|
|
2741
|
-
const dataLength = raw.length - padding;
|
|
2742
|
-
const firstPadding = raw.indexOf("=");
|
|
2743
|
-
if (raw.length % 4 !== 0 ||
|
|
2744
|
-
(firstPadding !== -1 && firstPadding !== dataLength)) {
|
|
2745
|
-
throw codedError("上传内容不是合法 Base64", "error.upload.encoding");
|
|
2746
|
-
}
|
|
2747
|
-
const decodedLength = (raw.length / 4) * 3 - padding;
|
|
2748
|
-
if (decodedLength > maxBytes)
|
|
2749
|
-
throw codedError(`上传内容超过 ${maxBytes} 字节限制`, code, {
|
|
2750
|
-
limit: maxBytes,
|
|
2751
|
-
});
|
|
2752
|
-
// 避免对数 MiB 字符串使用带重复分组的正则;V8 可能在合法大文件上耗尽调用栈。
|
|
2753
|
-
for (let index = 0; index < dataLength; index += 1) {
|
|
2754
|
-
const char = raw.charCodeAt(index);
|
|
2755
|
-
if (!((char >= 65 && char <= 90) ||
|
|
2756
|
-
(char >= 97 && char <= 122) ||
|
|
2757
|
-
(char >= 48 && char <= 57) ||
|
|
2758
|
-
char === 43 ||
|
|
2759
|
-
char === 47)) {
|
|
2760
|
-
throw codedError("上传内容不是合法 Base64", "error.upload.encoding");
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
const bytes = Buffer.from(raw, "base64");
|
|
2764
|
-
return bytes;
|
|
2765
|
-
}
|
|
2766
|
-
function uploadError(error) {
|
|
2767
|
-
// 按可选 message 读取抛出值,保持既有兜底文案。
|
|
2768
|
-
const cause = error;
|
|
2769
|
-
return attachCode({
|
|
2770
|
-
ok: false,
|
|
2771
|
-
error: String(cause && cause.message ? cause.message : error),
|
|
2772
|
-
}, error);
|
|
2773
|
-
}
|
|
2774
|
-
async function writeUploadedEntries(contentRoot, entries) {
|
|
2775
|
-
if (!Array.isArray(entries) || entries.length === 0)
|
|
2776
|
-
throw codedError("上传内容为空", "error.upload.empty");
|
|
2777
|
-
if (entries.length > MAX_UPLOAD_ENTRIES)
|
|
2778
|
-
throw codedError(`上传条目超过 ${MAX_UPLOAD_ENTRIES} 个`, "error.upload.tooMany", { limit: MAX_UPLOAD_ENTRIES });
|
|
2779
|
-
let total = 0;
|
|
2780
|
-
const seen = new Set();
|
|
2781
|
-
for (const entry of entries) {
|
|
2782
|
-
const normalized = normalizeUploadPath(entry && entry.path);
|
|
2783
|
-
const key = normalized.path.toLowerCase();
|
|
2784
|
-
if (seen.has(key))
|
|
2785
|
-
throw codedError(`上传内容包含重复路径: ${normalized.path}`, "error.upload.duplicate", { path: normalized.path });
|
|
2786
|
-
seen.add(key);
|
|
2787
|
-
if (normalized.directory)
|
|
2788
|
-
continue;
|
|
2789
|
-
const bytes = decodeUploadBase64(entry && entry.data, MAX_UPLOAD_ENTRY_BYTES);
|
|
2790
|
-
total += bytes.length;
|
|
2791
|
-
if (total > MAX_UPLOAD_TOTAL_BYTES)
|
|
2792
|
-
throw codedError(`上传内容总大小超过 ${MAX_UPLOAD_TOTAL_BYTES} 字节`, "error.upload.tooLarge", { limit: MAX_UPLOAD_TOTAL_BYTES });
|
|
2793
|
-
const target = join(contentRoot, ...normalized.path.split("/"));
|
|
2794
|
-
if (!isSameOrDescendant(contentRoot, target))
|
|
2795
|
-
throw codedError(`上传条目路径非法: ${normalized.path}`, "error.upload.path", { path: normalized.path });
|
|
2796
|
-
await fs.mkdir(dirname(target), { recursive: true });
|
|
2797
|
-
await fs.writeFile(target, bytes);
|
|
2798
|
-
}
|
|
2799
|
-
}
|
|
2800
|
-
async function writeUploadedZip(contentRoot, encoded) {
|
|
2801
|
-
const archive = decodeUploadBase64(encoded, MAX_UPLOAD_ARCHIVE_BYTES, "error.upload.archiveTooLarge");
|
|
2802
|
-
let count = 0;
|
|
2803
|
-
let total = 0;
|
|
2804
|
-
let files;
|
|
2805
|
-
try {
|
|
2806
|
-
files = unzipSync(archive, {
|
|
2807
|
-
filter(info) {
|
|
2808
|
-
const normalized = normalizeUploadPath(info.name);
|
|
2809
|
-
count += 1;
|
|
2810
|
-
if (count > MAX_UPLOAD_ENTRIES)
|
|
2811
|
-
throw codedError(`ZIP 条目超过 ${MAX_UPLOAD_ENTRIES} 个`, "error.upload.tooMany", { limit: MAX_UPLOAD_ENTRIES });
|
|
2812
|
-
if (!normalized.directory && info.originalSize > MAX_UPLOAD_ENTRY_BYTES)
|
|
2813
|
-
throw codedError(`ZIP 条目过大: ${normalized.path}`, "error.upload.tooLarge", { limit: MAX_UPLOAD_ENTRY_BYTES });
|
|
2814
|
-
total += normalized.directory ? 0 : info.originalSize;
|
|
2815
|
-
if (total > MAX_UPLOAD_TOTAL_BYTES)
|
|
2816
|
-
throw codedError(`ZIP 解压后总大小超过 ${MAX_UPLOAD_TOTAL_BYTES} 字节`, "error.upload.tooLarge", { limit: MAX_UPLOAD_TOTAL_BYTES });
|
|
2817
|
-
return !normalized.directory;
|
|
2818
|
-
},
|
|
2819
|
-
});
|
|
2820
|
-
}
|
|
2821
|
-
catch (error) {
|
|
2822
|
-
// 业务错误(我们抛的 error.* 前缀)透传;其余按 ZIP 解压失败处理。
|
|
2823
|
-
if (error && /^error\./.test(String(error.code || "")))
|
|
2824
|
-
throw error;
|
|
2825
|
-
throw codedError(`ZIP 无法解压: ${String(error && error.message ? error.message : error)}`, "error.upload.zipInvalid");
|
|
2826
|
-
}
|
|
2827
|
-
const entries = Object.entries(files).map(([path, bytes]) => ({
|
|
2828
|
-
path,
|
|
2829
|
-
data: Buffer.from(bytes).toString("base64"),
|
|
2830
|
-
}));
|
|
2831
|
-
await writeUploadedEntries(contentRoot, entries);
|
|
2832
|
-
}
|
|
2833
|
-
async function prepareUploadedSource(sessionRoot, input) {
|
|
2834
|
-
const contentRoot = join(sessionRoot, "content");
|
|
2835
|
-
await fs.mkdir(contentRoot, { recursive: true });
|
|
2836
|
-
if (input && input.zip !== undefined)
|
|
2837
|
-
await writeUploadedZip(contentRoot, input.zip);
|
|
2838
|
-
else
|
|
2839
|
-
await writeUploadedEntries(contentRoot, input && input.entries);
|
|
2840
|
-
const rootSkill = join(contentRoot, "SKILL.md");
|
|
2841
|
-
const rootSkillStat = await lstatOrNull(rootSkill);
|
|
2842
|
-
if (!rootSkillStat || !rootSkillStat.isFile())
|
|
2843
|
-
return contentRoot;
|
|
2844
|
-
const doc = parseSkillDoc(await fs.readFile(rootSkill, "utf8"));
|
|
2845
|
-
const fallback = String((input && input.name) || "uploaded-skill")
|
|
2846
|
-
.replace(/\.zip$/i, "")
|
|
2847
|
-
.replace(/^skill\.md$/i, "uploaded-skill");
|
|
2848
|
-
const skillName = toKebab(doc.map.name || fallback);
|
|
2849
|
-
if (!skillName ||
|
|
2850
|
-
!KEBAB_RE.test(skillName) ||
|
|
2851
|
-
entryPath(contentRoot, skillName) === null) {
|
|
2852
|
-
throw codedError(`无法生成合法 kebab-case 名称(原始名: ${doc.map.name || fallback})`, "error.import.invalidName", { name: doc.map.name || fallback });
|
|
2853
|
-
}
|
|
2854
|
-
const batchRoot = join(sessionRoot, "batch");
|
|
2855
|
-
const wrappedRoot = join(batchRoot, skillName);
|
|
2856
|
-
await fs.mkdir(batchRoot, { recursive: true });
|
|
2857
|
-
await renameWithRetry(contentRoot, wrappedRoot);
|
|
2858
|
-
return batchRoot;
|
|
2859
|
-
}
|
|
899
|
+
/** 浏览器上传来源入参(entries 或 zip 二者取一)。 */
|
|
2860
900
|
/**
|
|
2861
901
|
* 接收浏览器读取后的内容,在 manager 私有目录暂存并复用现有原子导入链路。
|
|
2862
902
|
* input: { name, entries:[{path,data(base64)}] } 或 { name, zip:base64 }。
|
|
2863
903
|
*/
|
|
2864
|
-
export async function importUploadedSkill(input, log, options = {}) {
|
|
2865
|
-
const uploadHome = join(managerHomePath(), "uploads");
|
|
2866
|
-
const sessionRoot = join(uploadHome, `.upload-${randomUUID()}`);
|
|
2867
|
-
try {
|
|
2868
|
-
await fs.mkdir(sessionRoot, { recursive: true });
|
|
2869
|
-
const source = await prepareUploadedSource(sessionRoot, input || {});
|
|
2870
|
-
return await importSkill(source, log, options);
|
|
2871
|
-
}
|
|
2872
|
-
catch (error) {
|
|
2873
|
-
return uploadError(error);
|
|
2874
|
-
}
|
|
2875
|
-
finally {
|
|
2876
|
-
await fs
|
|
2877
|
-
.rm(sessionRoot, { recursive: true, force: true })
|
|
2878
|
-
.catch(() => undefined);
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
904
|
// ── 创建 / 详情 / Provider ─────────────────────────────────────────────────
|
|
2882
905
|
function yamlString(value) {
|
|
2883
906
|
return JSON.stringify(String(value));
|
|
@@ -3110,7 +1133,17 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
|
|
|
3110
1133
|
sourceReadOnly: !root.mutable,
|
|
3111
1134
|
};
|
|
3112
1135
|
}
|
|
3113
|
-
/**
|
|
1136
|
+
/**
|
|
1137
|
+
* 生成 manager provider 候选:保留禁用候选以阻止低优先级重名副本意外激活。
|
|
1138
|
+
*
|
|
1139
|
+
* **同名首选必须传进分组**(2026-09-30 审查 F8)。此前这里调
|
|
1140
|
+
* `groupLoadableSkillsByName(items)` 时没带 `preferred`,而管理页(`markWinners`)带了 ——
|
|
1141
|
+
* 于是「同名首选」只在界面上生效:用户把 A 设为首选,界面把 A 标成「同名首选」、把 B 标成
|
|
1142
|
+
* 「被覆盖」,模型侧却仍按 rank 拿到 B。**界面说的和模型用的不是同一份**,且没有任何一处报错。
|
|
1143
|
+
*
|
|
1144
|
+
* 分组函数本身是共用的(同一份排序规则),缺的只是这个参数;传进去两侧即同源。
|
|
1145
|
+
* 未命中(键已失效 / 来源被移除)时分组函数自己退化为纯 rank 顺序,与界面一致。
|
|
1146
|
+
*/
|
|
3114
1147
|
export async function listProviderCandidates(options = {}) {
|
|
3115
1148
|
const policyResult = await readManagerState();
|
|
3116
1149
|
const candidates = [];
|
|
@@ -3129,7 +1162,9 @@ export async function listProviderCandidates(options = {}) {
|
|
|
3129
1162
|
items.push({ root, entry });
|
|
3130
1163
|
}
|
|
3131
1164
|
}
|
|
3132
|
-
|
|
1165
|
+
// 与管理页 markWinners 同一个 preferred 表、同一个分组函数 —— 界面与模型必须挑同一个赢家。
|
|
1166
|
+
const preferredSkills = policyResult.state.preferredSkills || null;
|
|
1167
|
+
for (const group of groupLoadableSkillsByName(items, preferredSkills).values()) {
|
|
3133
1168
|
const { root, entry } = group[0];
|
|
3134
1169
|
const project = root.scope === "project";
|
|
3135
1170
|
const policyOnly = root.mutable;
|
|
@@ -3249,246 +1284,11 @@ export async function getProviderSkill(candidate, options = {}) {
|
|
|
3249
1284
|
}
|
|
3250
1285
|
}
|
|
3251
1286
|
// ── 状态快照 ────────────────────────────────────────────────────────────────
|
|
3252
|
-
function canonicalSkillName(item) {
|
|
3253
|
-
return item.entry.declaredName || item.entry.name;
|
|
3254
|
-
}
|
|
3255
1287
|
/**
|
|
3256
1288
|
* 真实路径去重后按声明名分组;管理页与当前工作区 provider 共用来源优先级。
|
|
3257
1289
|
*
|
|
3258
1290
|
* `preferred`(技能名 → 来源 key)是用户对同名技能的显式选择:命中的来源排到同名前,
|
|
3259
1291
|
* 其余仍按来源 rank 排序;未命中(或键已失效)时退化为纯 rank 顺序。
|
|
3260
1292
|
*/
|
|
3261
|
-
export function groupLoadableSkillsByName(items, preferred) {
|
|
3262
|
-
const preferredFor = (item) => {
|
|
3263
|
-
if (!preferred)
|
|
3264
|
-
return null;
|
|
3265
|
-
const key = preferred[canonicalSkillName(item)];
|
|
3266
|
-
return typeof key === "string" ? key : null;
|
|
3267
|
-
};
|
|
3268
|
-
const ordered = [...items].sort((a, b) => {
|
|
3269
|
-
const rankA = preferredFor(a) === a.root.key ? 0 : 1;
|
|
3270
|
-
const rankB = preferredFor(b) === b.root.key ? 0 : 1;
|
|
3271
|
-
if (rankA !== rankB)
|
|
3272
|
-
return rankA - rankB;
|
|
3273
|
-
return a.root.rank - b.root.rank;
|
|
3274
|
-
});
|
|
3275
|
-
const groups = new Map();
|
|
3276
|
-
for (const item of ordered) {
|
|
3277
|
-
if (!item.entry.loadable)
|
|
3278
|
-
continue;
|
|
3279
|
-
const name = canonicalSkillName(item);
|
|
3280
|
-
const group = groups.get(name) || [];
|
|
3281
|
-
group.push(item);
|
|
3282
|
-
groups.set(name, group);
|
|
3283
|
-
}
|
|
3284
|
-
return groups;
|
|
3285
|
-
}
|
|
3286
|
-
function markWinners(items, options = {}) {
|
|
3287
|
-
const winners = new Map();
|
|
3288
|
-
const preferred = options.preferred;
|
|
3289
|
-
for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
|
|
3290
|
-
winners.set(canonicalName, winner);
|
|
3291
|
-
// 只有「首选命中且确实赢了」才算首选;来源被停用/移除时不谎报。
|
|
3292
|
-
if (preferred && preferred[canonicalName] === winner.root.key)
|
|
3293
|
-
winner.view.preferred = true;
|
|
3294
|
-
if (options.markShadowed !== false) {
|
|
3295
|
-
for (const item of shadowed)
|
|
3296
|
-
item.view.shadowedBy = {
|
|
3297
|
-
root: winner.root.key,
|
|
3298
|
-
name: winner.entry.name,
|
|
3299
|
-
};
|
|
3300
|
-
}
|
|
3301
|
-
if (options.markWinner !== false)
|
|
3302
|
-
winner.view.winner = true;
|
|
3303
|
-
winner.view.enabled = winner.policy.enabled;
|
|
3304
|
-
winner.view.canonicalName = canonicalName;
|
|
3305
|
-
}
|
|
3306
|
-
return winners;
|
|
3307
|
-
}
|
|
3308
1293
|
/** DSH、常见 Agent、自定义目录与活动 Session 项目根的技能快照。 */
|
|
3309
|
-
export async function state(options = {}) {
|
|
3310
|
-
const policyResult = await readManagerState();
|
|
3311
|
-
const removedKeys = new Set(Array.isArray(policyResult.state.removedSources)
|
|
3312
|
-
? policyResult.state.removedSources
|
|
3313
|
-
: []);
|
|
3314
|
-
// 已移除的来源仍然出现在 roots 里(界面要能显示「已移除」并允许恢复),但**不扫描**:
|
|
3315
|
-
// 技能不入列表、不参与重名决胜 —— 这正是用户要的「不读取这个文件夹」。
|
|
3316
|
-
// 注意这里必须用**全部**来源 + 自定义来源,不能只用 activeUserRoots(),
|
|
3317
|
-
// 否则被移除的来源会连行都不剩,界面就无从提供「恢复读取」。
|
|
3318
|
-
const user = [...userRoots(), ...customRootsFromState(policyResult.state)];
|
|
3319
|
-
const scannable = user.filter((root) => !removedKeys.has(root.key));
|
|
3320
|
-
const userScans = await scanDeduplicatedRoots(scannable);
|
|
3321
|
-
const projectWarnings = [];
|
|
3322
|
-
const scoped = await projectRoots(options.projectCwds, projectWarnings);
|
|
3323
|
-
const trash = await listTrash();
|
|
3324
|
-
const result = {
|
|
3325
|
-
roots: [],
|
|
3326
|
-
projects: [],
|
|
3327
|
-
trash,
|
|
3328
|
-
warnings: [
|
|
3329
|
-
...(policyResult.warning ? [policyResult.warning] : []),
|
|
3330
|
-
...projectWarnings,
|
|
3331
|
-
],
|
|
3332
|
-
};
|
|
3333
|
-
// 回收站没有自动清理:超龄条目通过 warnings 提示用户到 Skills 管理页处理,
|
|
3334
|
-
// 避免静默删除用户可能还想恢复的内容。
|
|
3335
|
-
const TRASH_STALE_DAYS = 30;
|
|
3336
|
-
const staleTrashCount = trash.filter((item) => {
|
|
3337
|
-
const deletedAt = Date.parse(String(item && item.deletedAt));
|
|
3338
|
-
return Number.isFinite(deletedAt) && Date.now() - deletedAt > TRASH_STALE_DAYS * 86400000;
|
|
3339
|
-
}).length;
|
|
3340
|
-
if (staleTrashCount > 0)
|
|
3341
|
-
result.warnings.push({
|
|
3342
|
-
code: "warning.trash.stale",
|
|
3343
|
-
params: { count: staleTrashCount, days: TRASH_STALE_DAYS },
|
|
3344
|
-
error: `回收站中有 ${staleTrashCount} 个条目已超过 ${TRASH_STALE_DAYS} 天,建议到 Skills 管理页清理`,
|
|
3345
|
-
});
|
|
3346
|
-
const all = [];
|
|
3347
|
-
for (const root of [...scoped, ...user]) {
|
|
3348
|
-
const isRemoved = removedKeys.has(root.key);
|
|
3349
|
-
const { exists, entries, truncated } = isRemoved
|
|
3350
|
-
? { exists: false, entries: [], truncated: false }
|
|
3351
|
-
: root.scope === "project"
|
|
3352
|
-
? (await scanDeduplicatedRoots([root])).get(root.key)
|
|
3353
|
-
: userScans.get(root.key);
|
|
3354
|
-
if (isRemoved) {
|
|
3355
|
-
// 已移除:登记一行(供界面显示与恢复),但没有任何技能。
|
|
3356
|
-
result.roots.push({
|
|
3357
|
-
key: root.key,
|
|
3358
|
-
...(root.kind ? { kind: root.kind } : {}),
|
|
3359
|
-
...(root.localeKey ? { localeKey: root.localeKey } : {}),
|
|
3360
|
-
path: root.path,
|
|
3361
|
-
label: root.label,
|
|
3362
|
-
mutable: root.mutable,
|
|
3363
|
-
deletable: root.deletable === true,
|
|
3364
|
-
removable: !isDefaultSkillSource(root) && root.scope !== "project",
|
|
3365
|
-
// 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
|
|
3366
|
-
custom: root.custom === true,
|
|
3367
|
-
// 默认来源(dsh / hub):界面据此隐藏来源开关、只显示「可管理」标记。
|
|
3368
|
-
defaultSource: isDefaultSkillSource(root),
|
|
3369
|
-
toggleable: root.toggleable,
|
|
3370
|
-
native: root.native,
|
|
3371
|
-
rank: root.rank,
|
|
3372
|
-
scope: root.scope || "user",
|
|
3373
|
-
exists: false,
|
|
3374
|
-
truncated: false,
|
|
3375
|
-
removed: true,
|
|
3376
|
-
enabled: false,
|
|
3377
|
-
skills: [],
|
|
3378
|
-
});
|
|
3379
|
-
continue;
|
|
3380
|
-
}
|
|
3381
|
-
// 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
|
|
3382
|
-
if (root.scope === "project" && !exists && root.kind !== "project-dsh")
|
|
3383
|
-
continue;
|
|
3384
|
-
if (truncated)
|
|
3385
|
-
result.warnings.push({
|
|
3386
|
-
code: "warning.scan.truncated",
|
|
3387
|
-
params: { path: root.path },
|
|
3388
|
-
error: `技能扫描达到遍历上限,部分技能未显示: ${root.path}`,
|
|
3389
|
-
});
|
|
3390
|
-
const skills = [];
|
|
3391
|
-
for (const e of entries) {
|
|
3392
|
-
const policy = effectiveSkillPolicy(policyResult, root, e);
|
|
3393
|
-
const managerEnabled = policyResult.writable !== false &&
|
|
3394
|
-
policy.sourceEnabled &&
|
|
3395
|
-
policy.override !== false;
|
|
3396
|
-
skills.push({
|
|
3397
|
-
name: e.name,
|
|
3398
|
-
declaredName: e.declaredName,
|
|
3399
|
-
kind: e.kind,
|
|
3400
|
-
description: e.description,
|
|
3401
|
-
modelInvocable: e.modelInvocable,
|
|
3402
|
-
userInvocable: e.userInvocable,
|
|
3403
|
-
invocationPolicyValid: e.invocationPolicyValid,
|
|
3404
|
-
hasFrontmatter: e.hasFrontmatter,
|
|
3405
|
-
loadable: e.loadable,
|
|
3406
|
-
managerEnabled,
|
|
3407
|
-
managerOverride: policy.override === undefined ? null : policy.override,
|
|
3408
|
-
effectiveModelInvocable: policy.modelInvocable,
|
|
3409
|
-
effectiveUserInvocable: policy.userInvocable,
|
|
3410
|
-
diagnostics: e.diagnostics,
|
|
3411
|
-
path: e.docPath,
|
|
3412
|
-
});
|
|
3413
|
-
all.push({
|
|
3414
|
-
root,
|
|
3415
|
-
entry: e,
|
|
3416
|
-
managerEnabled,
|
|
3417
|
-
policy,
|
|
3418
|
-
view: skills[skills.length - 1],
|
|
3419
|
-
});
|
|
3420
|
-
}
|
|
3421
|
-
result.roots.push({
|
|
3422
|
-
key: root.key,
|
|
3423
|
-
...(root.kind ? { kind: root.kind } : {}),
|
|
3424
|
-
...(root.localeKey ? { localeKey: root.localeKey } : {}),
|
|
3425
|
-
path: root.path,
|
|
3426
|
-
label: root.label,
|
|
3427
|
-
mutable: root.mutable,
|
|
3428
|
-
// 是否提供「删除」:dsh / hub / 项目级 .dsh/skills 为 true;
|
|
3429
|
-
// 外部 Agent 与自定义绝对路径来源为 false(只读)。
|
|
3430
|
-
deletable: root.deletable === true,
|
|
3431
|
-
toggleable: root.toggleable,
|
|
3432
|
-
native: root.native,
|
|
3433
|
-
rank: root.rank,
|
|
3434
|
-
scope: root.scope || "user",
|
|
3435
|
-
...(root.projectRoot
|
|
3436
|
-
? {
|
|
3437
|
-
projectRoot: root.projectRoot,
|
|
3438
|
-
projectName: root.projectName,
|
|
3439
|
-
workspaceCwds: root.workspaceCwds,
|
|
3440
|
-
}
|
|
3441
|
-
: {}),
|
|
3442
|
-
exists,
|
|
3443
|
-
truncated: truncated === true,
|
|
3444
|
-
// 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
|
|
3445
|
-
removable: !isDefaultSkillSource(root) && root.scope !== "project",
|
|
3446
|
-
// 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
|
|
3447
|
-
custom: root.custom === true,
|
|
3448
|
-
// 默认来源(dsh / hub):界面据此隐藏来源开关(它们不能停用),换成「可管理」标记。
|
|
3449
|
-
defaultSource: isDefaultSkillSource(root),
|
|
3450
|
-
removed: false,
|
|
3451
|
-
enabled: policyResult.writable !== false &&
|
|
3452
|
-
(root.scope === "project" ||
|
|
3453
|
-
isDefaultSkillSource(root) ||
|
|
3454
|
-
policyResult.state.sources[root.key] !== false),
|
|
3455
|
-
skills,
|
|
3456
|
-
});
|
|
3457
|
-
}
|
|
3458
|
-
const userItems = all.filter((item) => item.root.scope !== "project");
|
|
3459
|
-
const preferredSkills = policyResult.state.preferredSkills || null;
|
|
3460
|
-
markWinners(userItems, { preferred: preferredSkills });
|
|
3461
|
-
const projectGroups = new Map();
|
|
3462
|
-
for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
|
|
3463
|
-
const group = projectGroups.get(item.root.projectRoot) || [];
|
|
3464
|
-
group.push(item);
|
|
3465
|
-
projectGroups.set(item.root.projectRoot, group);
|
|
3466
|
-
}
|
|
3467
|
-
for (const projectItems of projectGroups.values()) {
|
|
3468
|
-
// 用户级条目参与该 workspace 的优先级判定,但不把其全局视图标记为“被某项目覆盖”。
|
|
3469
|
-
const userCopies = userItems.map((item) => ({
|
|
3470
|
-
...item,
|
|
3471
|
-
view: { ...item.view },
|
|
3472
|
-
}));
|
|
3473
|
-
markWinners([...projectItems, ...userCopies], { preferred: preferredSkills });
|
|
3474
|
-
}
|
|
3475
|
-
const seenProjects = new Set();
|
|
3476
|
-
for (const root of result.roots.filter((item) => item.scope === "project")) {
|
|
3477
|
-
if (seenProjects.has(root.projectRoot))
|
|
3478
|
-
continue;
|
|
3479
|
-
seenProjects.add(root.projectRoot);
|
|
3480
|
-
result.projects.push({
|
|
3481
|
-
root: root.projectRoot,
|
|
3482
|
-
name: root.projectName,
|
|
3483
|
-
workspaceCwds: root.workspaceCwds,
|
|
3484
|
-
});
|
|
3485
|
-
}
|
|
3486
|
-
result.summary = {
|
|
3487
|
-
total: all.length,
|
|
3488
|
-
enabled: all.filter((item) => item.view.enabled === true).length,
|
|
3489
|
-
disabled: all.filter((item) => item.entry.loadable && item.policy.enabled === false).length,
|
|
3490
|
-
issues: all.reduce((count, item) => count + item.entry.diagnostics.length + (item.view.shadowedBy ? 1 : 0), 0),
|
|
3491
|
-
};
|
|
3492
|
-
return result;
|
|
3493
|
-
}
|
|
3494
1294
|
export { KEBAB_RE };
|