dsh-toolbox-ui 1.6.5 → 1.6.7
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/README.en.md +4 -3
- package/README.md +4 -3
- package/docs/design.md +40 -10
- package/docs/installation.md +5 -5
- package/docs/skills.md +57 -10
- package/lib/client.js +265 -91
- package/lib/index.js +102 -88
- package/lib/skills-file-ops.js +304 -107
- package/lib/skills-frontmatter.js +48 -15
- package/lib/skills-registry.js +163 -43
- package/lib/typert.js +1 -0
- package/lib/whats-new.js +42 -0
- package/package.json +1 -1
package/lib/skills-file-ops.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
// 宿主侧 skills
|
|
1
|
+
// 宿主侧 skills 文件动作:启停、删除、导入、旧机制残留的迁移读取。
|
|
2
2
|
//
|
|
3
3
|
// 这一层真的动用户的文件,纪律比其它层严:
|
|
4
|
-
// - 按 (
|
|
4
|
+
// - 按 (根, 磁盘条目) 精确定位;找不到就报错,**绝不回退**到别的作用域
|
|
5
5
|
// (否则同名技能会被误删/误停用)。
|
|
6
6
|
// - bundled 根只读,拒绝写入。
|
|
7
|
-
// -
|
|
8
|
-
// - 启停用 rename:dsh-atomic-write 只提供 writeFileAtomic 与 withFileLock,没有 rename。
|
|
7
|
+
// - 启停写的是 frontmatter 的**两个调用面字段**(宿主原生),不搬文件——见 setSkillEnabled。
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
*
|
|
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 {
|
|
66
|
-
if (typeof
|
|
67
|
-
throw new Error(
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
return { ...base, kind: 'flat', path: `${flatPath}${DISABLED_SUFFIX}`,
|
|
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
|
-
*
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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,37 +187,84 @@ export async function setSkillEnabled(target, enabled) {
|
|
|
105
187
|
if (target.writable === false) {
|
|
106
188
|
throw new Error('该技能来自只读根(bundled),不可启停')
|
|
107
189
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
* 把两个调用面字段一次写成目标值,其余字节原样保留。
|
|
130
215
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
+
* 得到——扫盘正是上一版的第二真值来源(同身份双行、链接目录判错都出自那里)。
|
|
259
|
+
*
|
|
260
|
+
* 保留它的唯一理由:1.6.x 的用户磁盘上可能已经有 `.disabled` 文件(我们自己写进去的),
|
|
261
|
+
* 面板必须能把它们**列出来并恢复**,否则那些技能就永远回不来了。用户恢复之后,
|
|
262
|
+
* 想停用就走新机制;本函数与它的 wire 字段在下一个大版本可以整体删除。
|
|
133
263
|
*
|
|
134
264
|
* ⚠️ 「是不是目录」必须用 `stat`(跟随链接),**不能**用 `readdir` 的 `Dirent.isDirectory()`:
|
|
135
265
|
* 技能目录是指向外部同步库的符号链接/junction 时,Dirent 报的是 `isSymbolicLink() === true`
|
|
136
266
|
* 而 `isDirectory() === false`(实测)。用 Dirent 会让这类条目掉进扁平分支、既不匹配 `.md.disabled`
|
|
137
267
|
* 又拿不到 `<dir>/SKILL.md.disabled` —— **链接技能一旦停用就从面板彻底消失,再也启用不回来**。
|
|
138
|
-
* 这与 `locateSkill` 处理的是同一个根因:判据要落在物理位置(跟随链接后的真相)上。
|
|
139
268
|
*
|
|
140
269
|
* @param {{ root: string, scope: 'global'|'project' }} input
|
|
141
270
|
* @returns {Promise<object[]>}
|
|
@@ -155,23 +284,24 @@ export async function scanDisabledSkills(input) {
|
|
|
155
284
|
// 跟随链接判目录(见函数头说明)。stat 失败(悬空链接)就当它不是目录,继续按扁平判。
|
|
156
285
|
const isDirectory = await stat(join(root, entry.name)).then((info) => info.isDirectory()).catch(() => false)
|
|
157
286
|
if (isDirectory) {
|
|
158
|
-
//
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const disabled = join(root, name, `${BUNDLE_FILE}${DISABLED_SUFFIX}`)
|
|
287
|
+
// 目录捆绑:<目录名>/SKILL.md.disabled
|
|
288
|
+
const entryName = entry.name
|
|
289
|
+
const disabled = join(root, entryName, `${BUNDLE_FILE}${DISABLED_SUFFIX}`)
|
|
162
290
|
if (await exists(disabled)) {
|
|
163
|
-
rows.push({
|
|
291
|
+
rows.push({ entry: entryName, kind: 'bundle', scope, root, path: disabled, migrated: true })
|
|
164
292
|
}
|
|
165
293
|
continue
|
|
166
294
|
}
|
|
167
|
-
//
|
|
295
|
+
// 扁平:<文件名>.md.disabled
|
|
168
296
|
if (entry.name.endsWith(`.md${DISABLED_SUFFIX}`)) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 })
|
|
172
302
|
}
|
|
173
303
|
}
|
|
174
|
-
return rows
|
|
304
|
+
return rows
|
|
175
305
|
}
|
|
176
306
|
|
|
177
307
|
/**
|
|
@@ -192,69 +322,135 @@ export async function readSkillDocument(target) {
|
|
|
192
322
|
}
|
|
193
323
|
|
|
194
324
|
/**
|
|
195
|
-
*
|
|
325
|
+
* 改一个技能的**某一个调用面**——写它 frontmatter 里对应的那个字段。
|
|
196
326
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
327
|
+
* 两个面共用这一处实现(`face` 选哪个字段;字段名与缺省方向只在 `INVOCATION_FIELDS` 声明一次):
|
|
328
|
+
* · `modelInvocable` → `disable-model-invocation`(模型能不能自动发现并调用它)
|
|
329
|
+
* · `userInvocable` → `user-invocable`(你能不能 `/名字` 手动调用它)
|
|
330
|
+
*
|
|
331
|
+
* ⚠️ 为什么两个面**各自可写**,而不是只给「模型面」一个开关:宿主支持的是**两个**面,
|
|
332
|
+
* 而「停用」只是「两个都关」这一种组合。面板若只暴露一个面,用户在面板里就拨不回
|
|
333
|
+
* 「仅手动可调用」那一档——只能去编辑文件。补上第二个开关之后,面板与文件是 1:1 的
|
|
334
|
+
* (没有第三态要存,也没有派生状态要对账),而 `setSkillEnabled` 退回它本来的身份:
|
|
335
|
+
* 把两个面一起拨的**快捷动作**。
|
|
336
|
+
*
|
|
337
|
+
* 与启停同一套纪律:只读根拒绝、同一把锁(`lockKeyOf` = `<根>/<条目>`)、读-改-写全在锁内、
|
|
338
|
+
* 原子提交(writeFileAtomic 写临时文件再 rename)。改写规则本身在 lib/skills-frontmatter.js:
|
|
339
|
+
* 形状读不准时它抛错,本层**原样放行那个错误**——拒绝的原因只有那一处知道,包一层只会把
|
|
340
|
+
* 「哪一行不对」这类信息丢掉。
|
|
201
341
|
*
|
|
202
342
|
* @param {object} target - resolveSkillTarget 的结果(已停用的技能也收,权限与启用态无关)。
|
|
203
|
-
* @param {
|
|
204
|
-
* @
|
|
343
|
+
* @param {'modelInvocable'|'userInvocable'} face - 改哪一个调用面。
|
|
344
|
+
* @param {boolean} invocable - true 允许该面调用,false 禁止。
|
|
345
|
+
* @returns {Promise<{ changed: boolean, face: string, invocable: boolean }>}
|
|
205
346
|
*/
|
|
206
|
-
export async function
|
|
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
|
+
}
|
|
207
358
|
if (target.writable === false) {
|
|
208
359
|
throw new Error('该技能来自只读根(bundled),不可修改调用权限')
|
|
209
360
|
}
|
|
210
|
-
const next =
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
return await withFileLock(
|
|
361
|
+
const next = invocable !== false
|
|
362
|
+
// 锁键与启停/删除/导入一致(见 lockKeyOf):同一物理文件上的这些操作必须彼此串行,
|
|
363
|
+
// 否则「读到旧内容 → 写回」会把另一次操作的结果覆盖掉。
|
|
364
|
+
return await withFileLock(lockKeyOf(target), async () => {
|
|
214
365
|
const text = await readFile(target.path, 'utf8')
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return { changed: written.changed,
|
|
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 }
|
|
224
375
|
})
|
|
225
376
|
}
|
|
226
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
|
+
|
|
227
400
|
/**
|
|
228
401
|
* 删除一个技能(目录捆绑删整个目录,扁平删文件)。
|
|
229
|
-
*
|
|
402
|
+
*
|
|
403
|
+
* ⚠️ 只删**根下的那个条目**(`<根>/<条目>`),因此对「技能目录是 junction」的形态而言
|
|
404
|
+
* 删掉的是**链接本身**,外部同步库里的真实内容一个字节都不动——渲染端与门面都承诺过这一条,
|
|
405
|
+
* 而删错方向是本套动作里唯一不可逆的一步。
|
|
406
|
+
*
|
|
407
|
+
* 旧版约定留下的 `.disabled` 残骸一并清掉:用户对一条旧态技能点「删除」,期待的是
|
|
408
|
+
* 「这条技能不在了」,而不是「留下一个面板再也不显示的残骸」。
|
|
409
|
+
*
|
|
410
|
+
* @param {object} target - resolveSkillTarget 的结果。
|
|
230
411
|
*/
|
|
231
412
|
export async function removeSkill(target) {
|
|
232
413
|
if (target.writable === false) {
|
|
233
414
|
throw new Error('该技能来自只读根(bundled),不可删除')
|
|
234
415
|
}
|
|
235
|
-
//
|
|
236
|
-
return await withFileLock(
|
|
416
|
+
// 与启停/导入同一把锁:删除与「正在导入的同名技能」不能交错。
|
|
417
|
+
return await withFileLock(lockKeyOf(target), async () => {
|
|
418
|
+
// 展示路径(<根>/<条目>)——不是 realpath。见 resolveSkillTarget 的说明。
|
|
419
|
+
const item = join(target.root, target.entry)
|
|
237
420
|
if (target.kind === 'bundle') {
|
|
238
421
|
// 目录捆绑连同它的 resources(scripts/references/assets)一起删,
|
|
239
422
|
// 否则会在根里留下无主的资源目录。
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return { changed: true, removed: directory }
|
|
423
|
+
await rm(item, { recursive: true, force: true })
|
|
424
|
+
return { changed: true, removed: item }
|
|
243
425
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await rm(
|
|
247
|
-
return { changed: true, removed:
|
|
426
|
+
await rm(item, { force: true })
|
|
427
|
+
// 扁平技能的旧态残骸(同一个条目的 .disabled 变体)。
|
|
428
|
+
await rm(`${item}${DISABLED_SUFFIX}`, { force: true })
|
|
429
|
+
return { changed: true, removed: item }
|
|
248
430
|
})
|
|
249
431
|
}
|
|
250
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
|
+
|
|
251
447
|
// ---------------------------------------------------------------------------
|
|
252
448
|
// 导入
|
|
253
449
|
//
|
|
254
450
|
// 落盘名必须取自 **frontmatter 的 name**,而不是源文件/目录/压缩包内层的名字:宿主的技能
|
|
255
|
-
// 身份就来自 frontmatter(dsh-skill-filesystem
|
|
256
|
-
//
|
|
257
|
-
//
|
|
451
|
+
// 身份就来自 frontmatter(dsh-skill-filesystem 的 parsed.name),它完全忽略外层名字。
|
|
452
|
+
// 若按外层名落盘,导入结果的名字与用户看到的技能名就对不上——而**定位**不受影响:
|
|
453
|
+
// 定位走「根下的条目」(见 resolveSkillTarget),条目名与技能名本来就不必一致。
|
|
258
454
|
// ---------------------------------------------------------------------------
|
|
259
455
|
|
|
260
456
|
/** 列出一个技能树里所有 SKILL.md 的相对路径(正斜杠)。 */
|
|
@@ -375,7 +571,8 @@ export async function importSkill(input) {
|
|
|
375
571
|
await mkdir(root, { recursive: true })
|
|
376
572
|
// 锁 + 原子提交:临时名**刻意不带 `.md`**(宿主的扁平规则只认 `*.md`,所以它在扫描里不可见),
|
|
377
573
|
// 写完 rename 到位。并发同名导入因此不会互相踩,中途失败也不会留下半个技能文件。
|
|
378
|
-
|
|
574
|
+
// 锁键与后续操作同一处算(lockKeyOf):导入进来的条目,之后启停/删除要用同一把锁。
|
|
575
|
+
return await withFileLock(await lockKeyOf({ root, entry: name }), async () => {
|
|
379
576
|
await assertSkillVacant(root, name)
|
|
380
577
|
return await commitFlatSkill(root, name, text, scope)
|
|
381
578
|
})
|
|
@@ -406,8 +603,8 @@ export async function importSkill(input) {
|
|
|
406
603
|
const text = await readFile(join(bundleSource, BUNDLE_FILE), 'utf8')
|
|
407
604
|
const name = requireImportableName(text, sourcePath)
|
|
408
605
|
await mkdir(root, { recursive: true })
|
|
409
|
-
// 锁 +
|
|
410
|
-
return await withFileLock(
|
|
606
|
+
// 锁 + 原子提交(与启停/删除同一把锁键,见 lockKeyOf)。
|
|
607
|
+
return await withFileLock(await lockKeyOf({ root, entry: name }), async () => {
|
|
411
608
|
await assertSkillVacant(root, name)
|
|
412
609
|
return await commitBundleSkill(root, name, bundleSource, scope)
|
|
413
610
|
})
|