dsh-toolbox-ui 1.6.4 → 1.6.6

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.
@@ -1,14 +1,13 @@
1
- // 宿主侧 skills 文件动作:启停、删除、导入、扫描已禁用。
1
+ // 宿主侧 skills 文件动作:启停、删除、导入、旧机制残留的迁移读取。
2
2
  //
3
3
  // 这一层真的动用户的文件,纪律比其它层严:
4
- // - 按 (name, scope, root) 精确定位;找不到就报错,**绝不回退**到别的作用域
4
+ // - 按 (根, 磁盘条目) 精确定位;找不到就报错,**绝不回退**到别的作用域
5
5
  // (否则同名技能会被误删/误停用)。
6
6
  // - bundled 根只读,拒绝写入。
7
- // - 名字来自面板与远端目录,属外部输入,必须校验后才能拼进路径。
8
- // - 启停用 rename:dsh-atomic-write 只提供 writeFileAtomic 与 withFileLock,没有 rename。
7
+ // - 启停写的是 frontmatter 的**两个调用面字段**(宿主原生),不搬文件——见 setSkillEnabled。
9
8
  //
10
- // 宿主看不到已禁用的技能(SKILL.md 改名后按固定路径读不到 → 静默跳过),
11
- // 所以「已禁用」必须由本层自己扫 .disabled 得到,否则面板无法展示它们。
9
+ // ⚠️ 定位**不按技能名**(那是身份,不是位置):宿主允许磁盘条目名与 frontmatter 的 name
10
+ // 不一致,按名字拼路径会让那类技能在面板里所有操作都失败。见 resolveSkillTarget。
12
11
 
13
12
  import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
14
13
  import { randomBytes } from 'node:crypto'
@@ -16,10 +15,10 @@ import { tmpdir } from 'node:os'
16
15
  import { join, relative } from 'node:path'
17
16
  import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
18
17
  import { MAX_ARCHIVE_BYTES, extractArchive } from './skills-archive.js'
19
- import { diagnoseSkill, setFrontmatterBoolean } from './skills-frontmatter.js'
18
+ import { INVOCATION_FIELDS, diagnoseSkill, setFrontmatterBoolean } from './skills-frontmatter.js'
20
19
 
21
- // 与宿主同一套名字语法(dsh-skill 的 SKILL_NAME)。它天然排除 '.'、'/'、'\\',
22
- // 所以合法名字不可能跳出根目录——但越界校验仍然保留,因为这是系统边界。
20
+ // 与宿主同一套名字语法(dsh-skill 的 SKILL_NAME)。**导入**时用它校验 frontmatter 的 name
21
+ // (那是技能身份,宿主只接受这个语法);条目定位**不用**它——磁盘条目名不归宿主管,见 isSafeEntry。
23
22
  const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
24
23
 
25
24
  const BUNDLE_FILE = 'SKILL.md'
@@ -51,52 +50,135 @@ async function exists(path) {
51
50
  }
52
51
 
53
52
  /**
54
- * 把一个技能名解析成可操作的文件目标(形态由磁盘决定)。
53
+ * 定位一条技能 —— 按**宿主报告过的真实位置**,绝不按技能名拼路径。
55
54
  *
56
- * 找不到就抛错:绝不回退到别的作用域或别的形态。同名跨作用域是合法配置
57
- * (DSH 按 rank 裁决谁生效),静默回退会让用户停用/删掉另一个作用域的副本。
55
+ * ⚠️ 这一层的核心不变量(本文件与 skills-registry 共用):
58
56
  *
59
- * 形态判定必须看磁盘而不是信调用方声明——声明可能与磁盘不一致。
57
+ * 身份(frontmatter 的 `name`)只用于显示与 wire 上的指认;**定位一律以磁盘条目为准**。
60
58
  *
61
- * @param {{ name: string, scope: 'global'|'project', root: string, writable?: boolean }} input
59
+ * 为什么:宿主**允许**磁盘条目名与 frontmatter 的 `name` 不一致(实测:`dir-name/SKILL.md`
60
+ * 里写 `name: dir-alias` 照收,宿主按 frontmatter 取名)。曾经按名字拼
61
+ * `join(root, name, 'SKILL.md')` / `join(root, `${name}.md`)`,于是这类技能在面板里
62
+ * **所有操作都失败**(真机实测 6 种形态里 4 种完全不可操作,而宿主侧一切正常)。
63
+ *
64
+ * 所以入参是**条目**(该根下的直接子条目名,由宿主字段匹配得出,见 skills-registry 的
65
+ * locateSkill)而不是技能名。宿主发现只有一层(skill-filesystem 的 discoverRoot 只 readdir
66
+ * 一次),所以「根下的直接子条目」就是全部可能的位置,`<根>/<条目>[/SKILL.md]` 也因此在
67
+ * 结构上不可能跳出根——越界校验仍然保留,因为这是系统边界。
68
+ *
69
+ * ⚠️ 写盘一律用**展示路径**(`<根>/<条目>`),**绝不用 realpath**:技能的实体常常在用户的
70
+ * 同步库里(技能目录是 junction),对 realpath 改名/删除就是**动用户的数据**而不是动这个链接。
71
+ *
72
+ * @param {{ entry: string, kind: 'bundle'|'flat', scope: 'global'|'project', root: string, writable?: boolean }} input
62
73
  * @returns {Promise<object>} 带确定 kind 与 path 的目标。
63
74
  */
64
75
  export async function resolveSkillTarget(input) {
65
- const { name, scope, root, writable = true } = input
66
- if (typeof name !== 'string' || !SKILL_NAME.test(name)) {
67
- throw new Error(`非法技能名:${JSON.stringify(name)}(只接受 kebab-case)`)
76
+ const { entry, kind, scope, root, writable = true } = input
77
+ if (typeof entry !== 'string' || !isSafeEntry(entry)) {
78
+ throw new Error(`非法技能条目:${JSON.stringify(entry)}(只接受单个路径段)`)
68
79
  }
69
80
  if (typeof root !== 'string' || root === '') {
70
81
  throw new Error('非法技能根目录')
71
82
  }
72
- const bundlePath = join(root, name, BUNDLE_FILE)
73
- const flatPath = join(root, `${name}.md`)
74
- const base = { name, scope, root, writable, bundlePath, flatPath }
75
-
76
- if (await exists(bundlePath)) return { ...base, kind: 'bundle', path: bundlePath }
77
- if (await exists(flatPath)) return { ...base, kind: 'flat', path: flatPath }
78
- // 已禁用的两种形态也要能定位——「启用」正是从这个目标出发的。
79
- if (await exists(`${bundlePath}${DISABLED_SUFFIX}`)) {
80
- return { ...base, kind: 'bundle', path: `${bundlePath}${DISABLED_SUFFIX}`, disabled: true }
83
+
84
+ // ⚠️ **形态由磁盘决定**,不信任调用方声明(也不信任 wire 上的 `kind`)。
85
+ //
86
+ // 祖先实现就是这么做的(那时它按技能名探 `<根>/<名>/SKILL.md` 与 `<根>/<名>.md`),
87
+ // 中途改成「信 wire 的 kind」是一次退步:声明与磁盘不符时,算出的路径就指向别的东西
88
+ // (例如 `kind: flat` + 一个**目录**条目 → 路径落在那个目录上)。现在把探测推广到**条目**:
89
+ //
90
+ // ① `<根>/<条目>/SKILL.md` → 目录捆绑(宿主对目录 entry 的固定构造)
91
+ // ② `<根>/<条目>`(且它是**文件**) → 扁平(条目自带 `.md`)
92
+ // ③ ①②的 `.disabled` 变体 → 旧机制残留(一次性迁移读取)
93
+ //
94
+ // 探测顺序与宿主一致(`discoverRoot` 是**目录分支优先**,见 skill-filesystem/src/index.ts:735-739),
95
+ // 所以「同名既有 `x.md` 文件又有 `x.md/` 目录」这种歧义(POSIX 上可能)会与宿主解析成同一个。
96
+ //
97
+ // 这条也修掉了「条目名以 .md 结尾的**目录**」被误拒的缺陷:那种技能宿主完全合法
98
+ // (目录分支优先),而误拒会让整个 `list()` 抛错、面板技能页整块打不开。
99
+ const bundlePath = join(root, entry, BUNDLE_FILE)
100
+ const flatPath = join(root, entry)
101
+ const base = { name: entry, entry, scope, root, writable, bundlePath, flatPath }
102
+
103
+ if (await isFile(bundlePath)) return { ...base, kind: 'bundle', path: bundlePath }
104
+ if (await isFile(flatPath)) return { ...base, kind: 'flat', path: flatPath }
105
+ if (await isFile(`${bundlePath}${DISABLED_SUFFIX}`)) {
106
+ return { ...base, kind: 'bundle', path: `${bundlePath}${DISABLED_SUFFIX}`, migratedDisabled: true }
81
107
  }
82
- if (await exists(`${flatPath}${DISABLED_SUFFIX}`)) {
83
- return { ...base, kind: 'flat', path: `${flatPath}${DISABLED_SUFFIX}`, disabled: true }
108
+ if (await isFile(`${flatPath}${DISABLED_SUFFIX}`)) {
109
+ return { ...base, kind: 'flat', path: `${flatPath}${DISABLED_SUFFIX}`, migratedDisabled: true }
110
+ }
111
+ // 报错时带上调用方的说法(`kind`):它现在是**提示**而不是判据,但写进消息有助于定位
112
+ // 「面板以为它是扁平、磁盘上却是个目录」这类不一致。
113
+ throw new SkillNotFoundError(
114
+ `找不到技能条目 ${entry}(作用域 ${scope},根 ${root}${kind === undefined ? '' : `,面板当作 ${kind}`})`,
115
+ )
116
+ }
117
+
118
+ /**
119
+ * 这个路径是不是一个**普通文件**(跟随链接)。
120
+ *
121
+ * 必须问「是不是文件」而不是「存不存在」:扁平的探测路径 `<根>/<条目>` 在条目是个**目录**时
122
+ * 也存在(那是目录捆绑的目录本身),只判存在会让 `kind` 退化成一个指向目录的"技能文件"。
123
+ */
124
+ async function isFile(path) {
125
+ try {
126
+ return (await stat(path)).isFile()
127
+ } catch {
128
+ return false
84
129
  }
85
- throw new SkillNotFoundError(`找不到技能 ${name}(作用域 ${scope},根 ${root})`)
86
130
  }
87
131
 
88
132
  /**
89
- * 启用或停用一个技能。
90
- *
91
- * ⚠️ 判据基准必须是**启用态路径**,不能拿 `target.path` 直接当基准:技能已停用时,
92
- * `resolveSkillTarget` 返回的 `path` **本身就带 `.disabled`**(那是「它在哪」,不是「方向」)。
93
- * 上一版就是这么错的,两个方向都坏:
94
- * · 启用 → 算出的 `disabledPath` 是 `SKILL.md.disabled.disabled`(不存在)→ 静默 `{changed:false}`,
95
- * 用户点「启用」没有任何反应,而 UI 与文档都承诺能恢复;
96
- * · 停用 → 把已停用的技能再改名成 `SKILL.md.disabled.disabled`,还返回成功——那个文件名
97
- * 既不被 `scanDisabledSkills` 认(它只看 `.md.disabled` / `SKILL.md.disabled`),
98
- * 也不被宿主看到,**只能手工改名救回**。
99
- * 归一化之后两个方向都幂等:已经是目标态就 `{changed:false}`。
133
+ * 一个**磁盘条目名**是否安全:单个路径段,且不是 `.` / `..`。
134
+ *
135
+ * 判据比技能名宽(技能名是 kebab-case),因为**磁盘条目名不归宿主管**:宿主只要求目录条目
136
+ * 有 `SKILL.md`、文件条目以 `.md` 结尾,目录名本身可以是任何东西。收窄到技能名语法就会
137
+ * 重新制造「合法的技能操作不了」这一类缺陷;放宽到允许多段则会跳出根。
138
+ *
139
+ * ⚠️ 刻意**不**额外拦 `:`(Windows 上它是 NTFS 备用数据流的语法)。
140
+ * 理由是判据不能比宿主更严:POSIX 上 `foo:bar.md` 是**完全合法**的文件名,宿主也照收
141
+ * (它只要求 `.md` 结尾)——拦掉它就会重新造出「宿主认可、面板操作不了」那类缺陷,
142
+ * 也就是本轮 C1 的同一个坑。
143
+ * 而放行它的实际影响很小:`entry` 仍是一个路径段、仍被限定在宿主报告过的根内,
144
+ * 所以「删掉某个文件的备用数据流」并没有越过本层已有的权限边界(本层本来就能删这个根里的技能)。
145
+ * 记在这里是为了让下一个人知道这是**权衡后的决定**,不是漏看。
146
+ */
147
+ function isSafeEntry(entry) {
148
+ return entry !== '' && entry !== '.' && entry !== '..'
149
+ && !entry.includes('/') && !entry.includes('\\')
150
+ && !entry.includes('\0')
151
+ }
152
+
153
+ /**
154
+ * 启用 / 停用一条技能 —— 写宿主**原生的两个调用面字段**,不搬文件。
155
+ *
156
+ * 语义(与 VS Code / Copilot 官方文档里叫 “Disabled skills” 的那一档逐字同构):
157
+ *
158
+ * 停用 = `disable-model-invocation: true` **且** `user-invocable: false`
159
+ * 启用 = 两个字段都回到缺省(= 模型可调用 + 用户可调用)
160
+ *
161
+ * 为什么不再用「改名成 SKILL.md.disabled」这个旧机制(本插件 1.6.x 的自创做法):
162
+ *
163
+ * 1. 那个形态**不是任何规范认可的东西**。用户的技能常在 `~/.agents/skills`——Agent Skills
164
+ * 开放标准的共享位置,VS Code / Copilot、Claude Code、Codex 都扫它。我们把 `SKILL.md`
165
+ * 改名,等于为了本面板的一个开关,把用户的技能在**别的工具里**弄坏(VS Code 会因此
166
+ * 把它报成 Missing files)。这不是我们有权替用户做的取舍。
167
+ * 2. 状态只存在于文件名里 → 面板必须自己扫盘才能看见被停用的技能 → **第二真值来源**,
168
+ * 于是有了「宿主缓存与现场扫盘同身份双行」「链接目录的 Dirent 判错」这些缺陷。
169
+ * 改用原生字段后,被停用的技能**照样出现在宿主目录里**(list 是调用无关的),
170
+ * 面板不需要扫盘,那类缺陷在结构上消失。
171
+ *
172
+ * ⚠️ 代价,必须如实知道:**「启用」会把两个调用面都恢复成可调用**。若用户原本手工设了
173
+ * `disable-model-invocation: true`(只想要「仅用户可调用」那一档),点一次「停用」再「启用」
174
+ * 会把那档一起重置。所以面板把两个面**各自**做成一个可写开关(见 `setSkillInvocationFace`)
175
+ * ——重置了也能在面板上拨回来,不必去编辑文件。这也正是本函数保留的意义:
176
+ * 它是「两个面一起拨」的**快捷动作**,而不是唯一的操作方式。
177
+ *
178
+ * ⚠️ **旧机制残留(`migratedDisabled`)走另一条路**:那种技能的「停用」状态在**文件名**里
179
+ * (`SKILL.md.disabled`),不在 frontmatter 里——写字段对它毫无意义(宿主看不见那个文件)。
180
+ * 所以这个方向必须是 rename:把它改回 `SKILL.md`,宿主才会重新发现它。
181
+ * 「停用」方向不需要特例:残留被恢复之后就是普通技能,走上面的字段路径。
100
182
  *
101
183
  * @param {object} target - resolveSkillTarget 的结果。
102
184
  * @param {boolean} enabled - true 启用、false 停用。
@@ -105,31 +187,84 @@ export async function setSkillEnabled(target, enabled) {
105
187
  if (target.writable === false) {
106
188
  throw new Error('该技能来自只读根(bundled),不可启停')
107
189
  }
108
- const enabledPath = target.path.endsWith(DISABLED_SUFFIX)
109
- ? target.path.slice(0, -DISABLED_SUFFIX.length)
110
- : target.path
111
- const disabledPath = `${enabledPath}${DISABLED_SUFFIX}`
112
- // 读-改-写必须在锁内(SPEC 约束 20):exists → rename 之间被插入的话,
113
- // 两个面板/一次重试就会把技能改成谁都没见过的名字(`.disabled.disabled` 那类事故)。
114
- // 锁键用 `root + name`(与导入、删除同一把),所以同名操作彼此串行。
115
- return await withFileLock(join(target.root, target.name), async () => {
116
- if (enabled) {
117
- // 已经是启用态就幂等返回(面板可能重复点),否则把 .disabled 改回来。
118
- if (!(await exists(disabledPath))) return { changed: false }
119
- await rename(disabledPath, enabledPath)
120
- return { changed: true, enabled: true }
190
+ const on = enabled !== false
191
+ // 锁键用 `<根>/<条目>`:与导入/删除/改写权限**同一把**(见 lockKeyOf 的说明)。
192
+ return await withFileLock(lockKeyOf(target), async () => {
193
+ // 旧机制残留:恢复 = 把文件名改回去。**必须真 rename**,否则面板会说「已启用」而宿主
194
+ // 依然看不见它——用户点了没反应,还收到一条成功提示(这正是本层最贵的那类失败)。
195
+ if (target.migratedDisabled === true) {
196
+ if (!on) return { changed: false, enabled: false } // 它本来就处于停用态
197
+ const enabledPath = target.path.slice(0, -DISABLED_SUFFIX.length)
198
+ if (await exists(enabledPath)) {
199
+ // 目标名已被占用(用户手工建过、或两次恢复并发):不覆盖,如实报冲突。
200
+ throw new Error(`无法恢复:${enabledPath} 已存在(请先处理那份文件,不会自动覆盖)`)
201
+ }
202
+ await rename(target.path, enabledPath)
203
+ return { changed: true, enabled: true, migrated: true }
121
204
  }
122
- if (!(await exists(enabledPath))) return { changed: false }
123
- await rename(enabledPath, disabledPath)
124
- return { changed: true, enabled: false }
205
+ const text = await readFile(target.path, 'utf8')
206
+ // 两个字段一次写完再落盘:中途失败会留下「模型不可调用但用户可调用」这种没人要求过的中间态。
207
+ const next = setInvocation(text, { modelInvocable: on, userInvocable: on })
208
+ if (next.changed) await writeTextKeepingMode(target.path, next.text)
209
+ return { changed: next.changed, enabled: on }
125
210
  })
126
211
  }
127
212
 
128
213
  /**
129
- * 扫描一个根下所有**已禁用**的技能。
214
+ * 把两个调用面字段一次写成目标值,其余字节原样保留。
215
+ *
216
+ * 顺序固定为「先 user-invocable 再 disable-model-invocation」没有语义理由,只是为了确定性;
217
+ * 真正要紧的是**两个字段的名字与缺省方向**都从 INVOCATION_FIELDS 取(那处唯一声明)。
218
+ *
219
+ * @param {string} text - 技能文件全文。
220
+ * @param {{ modelInvocable: boolean, userInvocable: boolean }} invocation - 目标语义值。
221
+ * @returns {{ text: string, changed: boolean }}
222
+ */
223
+ function setInvocation(text, invocation) {
224
+ let current = text
225
+ let changed = false
226
+ for (const [field, spec] of Object.entries(INVOCATION_FIELDS)) {
227
+ const semantic = invocation[field] !== false
228
+ // ⚠️ 两层映射都要做对,任何一层反了都会静默写出**语义相反**的文件:
229
+ // ① 策略值 → 字段值:`disable-model-invocation` 说的是「禁止」,所以它是 modelInvocable 的**反**
230
+ // (`inverted`);`user-invocable` 与 userInvocable 同向。
231
+ // ② 字段值 + 该字段自己的缺省(`absentMeans`)→ 写还是删(由 setFrontmatterBoolean 决定)。
232
+ // 曾经漏掉 ①,于是「停用」只写了 user-invocable,而「启用」反而写出
233
+ // `disable-model-invocation: true`(把模型面关掉了)——两个方向都反,且都报成功。
234
+ const fieldValue = spec.inverted ? !semantic : semantic
235
+ const written = setFrontmatterBoolean(current, spec.key, fieldValue, spec.absentMeans)
236
+ if (written.changed) changed = true
237
+ current = written.text
238
+ }
239
+ return { text: current, changed }
240
+ }
241
+
242
+ /**
243
+ * 原子替换一个技能文件,**保留它的权限位**。
244
+ *
245
+ * writeFileAtomic 是「写临时文件再 rename」,新文件的权限位会退回默认值。技能文件是用户自己的
246
+ * 资产,动它一行不该顺手把它的权限也改掉。
247
+ */
248
+ async function writeTextKeepingMode(path, text) {
249
+ const { mode } = await stat(path)
250
+ await writeFileAtomic(path, text, { mode })
251
+ }
252
+
253
+ /**
254
+ * 扫描一个根下**旧版约定**(`SKILL.md.disabled` / `<x>.md.disabled`)留下的条目。
255
+ *
256
+ * ⚠️ 这是**一次性迁移读取**,不是「已停用技能」的来源。新机制下「停用」= frontmatter 里的两个
257
+ * 调用面字段(见 setSkillEnabled),那种技能**在宿主目录里照样看得见**,不需要也不该由本层扫盘
258
+ * 得到——扫盘正是上一版的第二真值来源(同身份双行、链接目录判错都出自那里)。
130
259
  *
131
- * 宿主目录看不到它们(改名后按固定路径读不到即静默跳过),所以这是面板能展示
132
- * 「已停用」并提供「启用」的唯一来源。
260
+ * 保留它的唯一理由:1.6.x 的用户磁盘上可能已经有 `.disabled` 文件(我们自己写进去的),
261
+ * 面板必须能把它们**列出来并恢复**,否则那些技能就永远回不来了。用户恢复之后,
262
+ * 想停用就走新机制;本函数与它的 wire 字段在下一个大版本可以整体删除。
263
+ *
264
+ * ⚠️ 「是不是目录」必须用 `stat`(跟随链接),**不能**用 `readdir` 的 `Dirent.isDirectory()`:
265
+ * 技能目录是指向外部同步库的符号链接/junction 时,Dirent 报的是 `isSymbolicLink() === true`
266
+ * 而 `isDirectory() === false`(实测)。用 Dirent 会让这类条目掉进扁平分支、既不匹配 `.md.disabled`
267
+ * 又拿不到 `<dir>/SKILL.md.disabled` —— **链接技能一旦停用就从面板彻底消失,再也启用不回来**。
133
268
  *
134
269
  * @param {{ root: string, scope: 'global'|'project' }} input
135
270
  * @returns {Promise<object[]>}
@@ -145,24 +280,28 @@ export async function scanDisabledSkills(input) {
145
280
  }
146
281
  const rows = []
147
282
  for (const entry of entries) {
148
- if (entry.isDirectory()) {
149
- // 目录捆绑:<name>/SKILL.md.disabled
150
- const name = entry.name
151
- if (!SKILL_NAME.test(name)) continue
152
- const disabled = join(root, name, `${BUNDLE_FILE}${DISABLED_SUFFIX}`)
283
+ if (typeof entry.name !== 'string') continue
284
+ // 跟随链接判目录(见函数头说明)。stat 失败(悬空链接)就当它不是目录,继续按扁平判。
285
+ const isDirectory = await stat(join(root, entry.name)).then((info) => info.isDirectory()).catch(() => false)
286
+ if (isDirectory) {
287
+ // 目录捆绑:<目录名>/SKILL.md.disabled
288
+ const entryName = entry.name
289
+ const disabled = join(root, entryName, `${BUNDLE_FILE}${DISABLED_SUFFIX}`)
153
290
  if (await exists(disabled)) {
154
- rows.push({ name, scope, root, kind: 'bundle', path: disabled, disabled: true })
291
+ rows.push({ entry: entryName, kind: 'bundle', scope, root, path: disabled, migrated: true })
155
292
  }
156
293
  continue
157
294
  }
158
- // 扁平:<name>.md.disabled
159
- if (typeof entry.name === 'string' && entry.name.endsWith(`.md${DISABLED_SUFFIX}`)) {
160
- const name = entry.name.slice(0, -`.md${DISABLED_SUFFIX}`.length)
161
- if (!SKILL_NAME.test(name)) continue
162
- rows.push({ name, scope, root, kind: 'flat', path: join(root, entry.name), disabled: true })
295
+ // 扁平:<文件名>.md.disabled
296
+ if (entry.name.endsWith(`.md${DISABLED_SUFFIX}`)) {
297
+ // ⚠️ 报出去的 entry 是**启用态那一段**(`x.md`),不是磁盘上这个 `.disabled` 文件名:
298
+ // 面板拿它去 resolveSkillTarget,而那里是按「启用态路径 + .disabled」两处找的
299
+ // (见 resolveSkillTarget)。给 `.disabled` 全名会连形态校验都过不去(不以 .md 结尾)。
300
+ const entryName = entry.name.slice(0, -DISABLED_SUFFIX.length)
301
+ rows.push({ entry: entryName, kind: 'flat', scope, root, path: join(root, entry.name), migrated: true })
163
302
  }
164
303
  }
165
- return rows.sort((left, right) => left.name.localeCompare(right.name))
304
+ return rows
166
305
  }
167
306
 
168
307
  /**
@@ -183,69 +322,135 @@ export async function readSkillDocument(target) {
183
322
  }
184
323
 
185
324
  /**
186
- * 改一个技能的「模型自动调用」权限——写的是它 frontmatter 里的 disable-model-invocation。
325
+ * 改一个技能的**某一个调用面**——写它 frontmatter 里对应的那个字段。
326
+ *
327
+ * 两个面共用这一处实现(`face` 选哪个字段;字段名与缺省方向只在 `INVOCATION_FIELDS` 声明一次):
328
+ * · `modelInvocable` → `disable-model-invocation`(模型能不能自动发现并调用它)
329
+ * · `userInvocable` → `user-invocable`(你能不能 `/名字` 手动调用它)
187
330
  *
188
- * 与启停同一套纪律:只读根拒绝、同一把锁(root + name,见 setSkillEnabled 的说明)、
189
- * 读-改-写全在锁内、原子提交(writeFileAtomic 写临时文件再 rename)。改写规则本身在
190
- * lib/skills-frontmatter.js:形状读不准时它抛错,本层**原样放行那个错误**——拒绝的原因
191
- * 只有那一处知道,包一层只会把「哪一行不对」这类信息丢掉。
331
+ * ⚠️ 为什么两个面**各自可写**,而不是只给「模型面」一个开关:宿主支持的是**两个**面,
332
+ * 而「停用」只是「两个都关」这一种组合。面板若只暴露一个面,用户在面板里就拨不回
333
+ * 「仅手动可调用」那一档——只能去编辑文件。补上第二个开关之后,面板与文件是 1:1 的
334
+ * (没有第三态要存,也没有派生状态要对账),而 `setSkillEnabled` 退回它本来的身份:
335
+ * 把两个面一起拨的**快捷动作**。
336
+ *
337
+ * 与启停同一套纪律:只读根拒绝、同一把锁(`lockKeyOf` = `<根>/<条目>`)、读-改-写全在锁内、
338
+ * 原子提交(writeFileAtomic 写临时文件再 rename)。改写规则本身在 lib/skills-frontmatter.js:
339
+ * 形状读不准时它抛错,本层**原样放行那个错误**——拒绝的原因只有那一处知道,包一层只会把
340
+ * 「哪一行不对」这类信息丢掉。
192
341
  *
193
342
  * @param {object} target - resolveSkillTarget 的结果(已停用的技能也收,权限与启用态无关)。
194
- * @param {boolean} modelInvocable - true 允许模型自动调用,false 禁止。
195
- * @returns {Promise<{ changed: boolean, modelInvocable: boolean }>}
343
+ * @param {'modelInvocable'|'userInvocable'} face - 改哪一个调用面。
344
+ * @param {boolean} invocable - true 允许该面调用,false 禁止。
345
+ * @returns {Promise<{ changed: boolean, face: string, invocable: boolean }>}
196
346
  */
197
- export async function setSkillModelInvocable(target, modelInvocable) {
347
+ export async function setSkillInvocationFace(target, face, invocable) {
348
+ // ⚠️ 必须用 `Object.hasOwn` 而不是 `INVOCATION_FIELDS[face]` 直接取:
349
+ // 面名来自 wire,而 `INVOCATION_FIELDS` 是个普通对象字面量——`'constructor'` / `'toString'`
350
+ // 这类名字会沿原型链取到函数(truthy),于是绕过白名单、继续走到 `spec.key === undefined`,
351
+ // 最后按 `undefined` 去写 frontmatter(实测:报错点从「未知的调用面」漂到一个看不懂的 YAML 错误)。
352
+ const spec = Object.hasOwn(INVOCATION_FIELDS, face) ? INVOCATION_FIELDS[face] : undefined
353
+ if (spec === undefined) {
354
+ // 白名单而不是「按面名拼一个字段名」:拼错会写出一个宿主不认识的键,
355
+ // 那既不改行为、又污染用户的 frontmatter。
356
+ throw new Error(`未知的调用面:${JSON.stringify(face)}(只接受 ${Object.keys(INVOCATION_FIELDS).join(' / ')})`)
357
+ }
198
358
  if (target.writable === false) {
199
359
  throw new Error('该技能来自只读根(bundled),不可修改调用权限')
200
360
  }
201
- const next = modelInvocable !== false
202
- // 锁键与启停/删除/导入一致:同名技能上的这些操作必须彼此串行,否则「读到旧内容 → 写回」
203
- // 会把另一次操作的结果覆盖掉(例如与删除交错时把技能文件重新写出来)。
204
- return await withFileLock(join(target.root, target.name), async () => {
361
+ const next = invocable !== false
362
+ // 锁键与启停/删除/导入一致(见 lockKeyOf):同一物理文件上的这些操作必须彼此串行,
363
+ // 否则「读到旧内容 → 写回」会把另一次操作的结果覆盖掉。
364
+ return await withFileLock(lockKeyOf(target), async () => {
205
365
  const text = await readFile(target.path, 'utf8')
206
- // 字段名说的是「禁止」而不是「允许」:modelInvocable === false ⇔ disable-model-invocation: true。
207
- const written = setFrontmatterBoolean(text, 'disable-model-invocation', !next)
208
- if (written.changed) {
209
- // 原子替换是「写临时文件再 rename」,新文件的权限位会退回默认值。技能文件是用户自己的
210
- // 资产,动它一个字不该顺手把它的权限也改掉——照抄 profile 写盘的纪律,保留原 mode。
211
- const { mode } = await stat(target.path)
212
- await writeFileAtomic(target.path, written.text, { mode })
213
- }
214
- return { changed: written.changed, modelInvocable: next }
366
+ // ⚠️ 两层映射都要做对,任何一层反了都会静默写出**语义相反**的文件:
367
+ // ① 语义值 → 字段值:`disable-model-invocation` 说的是「禁止」,所以它是 modelInvocable 的**反**
368
+ // (`inverted`);`user-invocable` 与 userInvocable 同向。
369
+ // ② 字段值 + 该字段自己的缺省(`absentMeans`)→ 写还是删(由 setFrontmatterBoolean 决定:
370
+ // 等于缺省就删掉那一行,于是「开→关→开」能逐字节还原原文)。
371
+ const fieldValue = spec.inverted ? !next : next
372
+ const written = setFrontmatterBoolean(text, spec.key, fieldValue, spec.absentMeans)
373
+ if (written.changed) await writeTextKeepingMode(target.path, written.text)
374
+ return { changed: written.changed, face, invocable: next }
215
375
  })
216
376
  }
217
377
 
378
+ /**
379
+ * 改「模型自动调用」权限(`setSkillInvocationFace` 的具名包装,保留原有调用形状与返回字段)。
380
+ * @param {object} target - resolveSkillTarget 的结果。
381
+ * @param {boolean} modelInvocable - true 允许模型自动调用,false 禁止。
382
+ * @returns {Promise<{ changed: boolean, modelInvocable: boolean }>}
383
+ */
384
+ export async function setSkillModelInvocable(target, modelInvocable) {
385
+ const result = await setSkillInvocationFace(target, 'modelInvocable', modelInvocable)
386
+ return { changed: result.changed, modelInvocable: result.invocable }
387
+ }
388
+
389
+ /**
390
+ * 改「用户可调用」权限(`/名字` 手动调用那个面)。
391
+ * @param {object} target - resolveSkillTarget 的结果。
392
+ * @param {boolean} userInvocable - true 允许手动调用,false 禁止。
393
+ * @returns {Promise<{ changed: boolean, userInvocable: boolean }>}
394
+ */
395
+ export async function setSkillUserInvocable(target, userInvocable) {
396
+ const result = await setSkillInvocationFace(target, 'userInvocable', userInvocable)
397
+ return { changed: result.changed, userInvocable: result.invocable }
398
+ }
399
+
218
400
  /**
219
401
  * 删除一个技能(目录捆绑删整个目录,扁平删文件)。
220
- * @param {object} target
402
+ *
403
+ * ⚠️ 只删**根下的那个条目**(`<根>/<条目>`),因此对「技能目录是 junction」的形态而言
404
+ * 删掉的是**链接本身**,外部同步库里的真实内容一个字节都不动——渲染端与门面都承诺过这一条,
405
+ * 而删错方向是本套动作里唯一不可逆的一步。
406
+ *
407
+ * 旧版约定留下的 `.disabled` 残骸一并清掉:用户对一条旧态技能点「删除」,期待的是
408
+ * 「这条技能不在了」,而不是「留下一个面板再也不显示的残骸」。
409
+ *
410
+ * @param {object} target - resolveSkillTarget 的结果。
221
411
  */
222
412
  export async function removeSkill(target) {
223
413
  if (target.writable === false) {
224
414
  throw new Error('该技能来自只读根(bundled),不可删除')
225
415
  }
226
- // 与启停/导入同一把锁(root + name):删除与「正在导入的同名技能」不能交错。
227
- return await withFileLock(join(target.root, target.name), async () => {
416
+ // 与启停/导入同一把锁:删除与「正在导入的同名技能」不能交错。
417
+ return await withFileLock(lockKeyOf(target), async () => {
418
+ // 展示路径(<根>/<条目>)——不是 realpath。见 resolveSkillTarget 的说明。
419
+ const item = join(target.root, target.entry)
228
420
  if (target.kind === 'bundle') {
229
421
  // 目录捆绑连同它的 resources(scripts/references/assets)一起删,
230
422
  // 否则会在根里留下无主的资源目录。
231
- const directory = join(target.root, target.name)
232
- await rm(directory, { recursive: true, force: true })
233
- return { changed: true, removed: directory }
423
+ await rm(item, { recursive: true, force: true })
424
+ return { changed: true, removed: item }
234
425
  }
235
- // 扁平文件:两种形态都清掉,避免留下一个 .md.disabled 残骸。
236
- await rm(join(target.root, `${target.name}.md`), { force: true })
237
- await rm(join(target.root, `${target.name}.md${DISABLED_SUFFIX}`), { force: true })
238
- return { changed: true, removed: join(target.root, `${target.name}.md`) }
426
+ await rm(item, { force: true })
427
+ // 扁平技能的旧态残骸(同一个条目的 .disabled 变体)。
428
+ await rm(`${item}${DISABLED_SUFFIX}`, { force: true })
429
+ return { changed: true, removed: item }
239
430
  })
240
431
  }
241
432
 
433
+ /**
434
+ * 一把锁的键:**根下那个条目的展示路径**(`<根>/<条目>`)。
435
+ *
436
+ * 为什么不是实体的 realpath:锁必须让「创建」与「后续操作」用同一把钥匙,
437
+ * 而创建(importSkill)此刻还没有实体、只能用展示路径算键。若操作侧改用 realpath,
438
+ * 技能目录是 junction 时两者立刻分叉——导入与删除/启停就会互相插队,正是这把锁要防的事。
439
+ *
440
+ * 「同一个物理文件被两个根指到」的情形(DSH_HOME 落在工作区里)由 skills-registry 的
441
+ * 物理位置去重在上游消除:面板因此只会拿**其中一个**根去寻址同一个文件,不会有两个键。
442
+ */
443
+ function lockKeyOf(target) {
444
+ return join(target.root, target.entry)
445
+ }
446
+
242
447
  // ---------------------------------------------------------------------------
243
448
  // 导入
244
449
  //
245
450
  // 落盘名必须取自 **frontmatter 的 name**,而不是源文件/目录/压缩包内层的名字:宿主的技能
246
- // 身份就来自 frontmatter(dsh-skill-filesystem/lib/index.js:696-704 的 parsed.name),
247
- // 它完全忽略外层名字。若按外层名落盘,本层自己的 resolveSkillTarget(name) 会找不到刚导入
248
- // 的技能——用户导入完面板就点不动它,而且宿主展示的名字与目录名还会不一致。
451
+ // 身份就来自 frontmatter(dsh-skill-filesystem 的 parsed.name),它完全忽略外层名字。
452
+ // 若按外层名落盘,导入结果的名字与用户看到的技能名就对不上——而**定位**不受影响:
453
+ // 定位走「根下的条目」(见 resolveSkillTarget),条目名与技能名本来就不必一致。
249
454
  // ---------------------------------------------------------------------------
250
455
 
251
456
  /** 列出一个技能树里所有 SKILL.md 的相对路径(正斜杠)。 */
@@ -366,7 +571,8 @@ export async function importSkill(input) {
366
571
  await mkdir(root, { recursive: true })
367
572
  // 锁 + 原子提交:临时名**刻意不带 `.md`**(宿主的扁平规则只认 `*.md`,所以它在扫描里不可见),
368
573
  // 写完 rename 到位。并发同名导入因此不会互相踩,中途失败也不会留下半个技能文件。
369
- return await withFileLock(join(root, name), async () => {
574
+ // 锁键与后续操作同一处算(lockKeyOf):导入进来的条目,之后启停/删除要用同一把锁。
575
+ return await withFileLock(await lockKeyOf({ root, entry: name }), async () => {
370
576
  await assertSkillVacant(root, name)
371
577
  return await commitFlatSkill(root, name, text, scope)
372
578
  })
@@ -397,8 +603,8 @@ export async function importSkill(input) {
397
603
  const text = await readFile(join(bundleSource, BUNDLE_FILE), 'utf8')
398
604
  const name = requireImportableName(text, sourcePath)
399
605
  await mkdir(root, { recursive: true })
400
- // 锁 + 原子提交(与启停/删除同一把锁键 root + name)。
401
- return await withFileLock(join(root, name), async () => {
606
+ // 锁 + 原子提交(与启停/删除同一把锁键,见 lockKeyOf)。
607
+ return await withFileLock(await lockKeyOf({ root, entry: name }), async () => {
402
608
  await assertSkillVacant(root, name)
403
609
  return await commitBundleSkill(root, name, bundleSource, scope)
404
610
  })