dsh-toolbox-ui 1.6.1 → 1.6.3

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.
@@ -25,15 +25,17 @@ const LEGACY_INVOCATION_KEYS = new Map([
25
25
  ])
26
26
 
27
27
  /**
28
- * 拆出 frontmatter 与正文,逐字照抄宿主 parseFrontmatter(dsh-skill-filesystem:780-806)。
28
+ * frontmatter 的字节范围——**解析与改写共用的唯一一套边界判据**。
29
29
  *
30
- * 宿主在这里的宽容度比一般实现低:首行必须**恰好**是 `---`(去尾随 `\r` 后比较),
31
- * 结束行同理。任何偏差都返回 ok:false,而不是猜一个边界。
30
+ * 为什么抽出来:读与写必须认同一个块。解析认得的块和改写动的块一旦分叉,后果是**静默改坏
31
+ * 用户的技能文件**,而且只在手写的、形状不标准的文件上才会踩到——测试里最容易漏的那一类。
32
32
  *
33
33
  * @param {string} raw - 技能文件全文。
34
- * @returns {{ ok: true, data: object, body: string } | { ok: false, code: string, message: string }}
34
+ * @returns {{ ok: true, fieldsStart: number, closingStart: number, bodyStart: number, eol: string }
35
+ * | { ok: false, code: string, message: string }} fieldsStart/closingStart 界定字段区,
36
+ * bodyStart 是正文起点,eol 是这份文件自己的行尾风格(插入新行时照用,别把 CRLF 写成混行)。
35
37
  */
36
- export function parseSkillFrontmatter(raw) {
38
+ function frontmatterSpan(raw) {
37
39
  if (typeof raw !== 'string') {
38
40
  return { ok: false, code: 'frontmatter-missing', message: '技能内容必须是文本' }
39
41
  }
@@ -45,15 +47,37 @@ export function parseSkillFrontmatter(raw) {
45
47
  return { ok: false, code: 'frontmatter-missing', message: '技能缺少 YAML frontmatter:首行必须是 ---' }
46
48
  }
47
49
 
48
- const start = firstLineEnd + 1
49
- const closing = findClosingFrontmatter(raw, start)
50
+ const fieldsStart = firstLineEnd + 1
51
+ const closing = findClosingFrontmatter(raw, fieldsStart)
50
52
  if (closing === undefined) {
51
53
  return { ok: false, code: 'frontmatter-missing', message: '技能缺少 YAML frontmatter:找不到结束的 --- 行' }
52
54
  }
55
+ return {
56
+ ok: true,
57
+ fieldsStart,
58
+ closingStart: closing.start,
59
+ bodyStart: closing.bodyStart,
60
+ eol: raw[firstLineEnd - 1] === '\r' ? '\r\n' : '\n',
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 拆出 frontmatter 与正文,逐字照抄宿主 parseFrontmatter(dsh-skill-filesystem:780-806)。
66
+ *
67
+ * 宿主在这里的宽容度比一般实现低:首行必须**恰好**是 `---`(去尾随 `\r` 后比较),
68
+ * 结束行同理。任何偏差都返回 ok:false,而不是猜一个边界。
69
+ *
70
+ * @param {string} raw - 技能文件全文。
71
+ * @returns {{ ok: true, data: object, body: string, span: object }
72
+ * | { ok: false, code: string, message: string }}
73
+ */
74
+ export function parseSkillFrontmatter(raw) {
75
+ const span = frontmatterSpan(raw)
76
+ if (!span.ok) return span
53
77
 
54
78
  let parsed
55
79
  try {
56
- parsed = parse(raw.slice(start, closing.start))
80
+ parsed = parse(raw.slice(span.fieldsStart, span.closingStart))
57
81
  } catch (error) {
58
82
  return {
59
83
  ok: false,
@@ -64,7 +88,8 @@ export function parseSkillFrontmatter(raw) {
64
88
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
65
89
  return { ok: false, code: 'frontmatter-not-mapping', message: 'YAML frontmatter 必须是一个键值映射' }
66
90
  }
67
- return { ok: true, data: parsed, body: raw.slice(closing.bodyStart) }
91
+ // span 一并交出去:改写方要用**同一套**边界(见 frontmatterSpan 的说明),不该自己再算一遍。
92
+ return { ok: true, data: parsed, body: raw.slice(span.bodyStart), span }
68
93
  }
69
94
 
70
95
  /** 找结束的 `---` 行(照抄宿主 findClosingFrontmatter:794-806)。 */
@@ -115,6 +140,121 @@ function frontmatterBoolean(data, key) {
115
140
  throw new TypeError(`frontmatter 字段 "${key}" 必须是布尔值`)
116
141
  }
117
142
 
143
+ // ── 改写一个顶层布尔字段 ────────────────────────────────────────────────────
144
+ //
145
+ // 面板的「模型自动调用」开关就落在这里(写 SKILL.md 的 disable-model-invocation)。三条不变量:
146
+ //
147
+ // 1. **只动该动的那一行**:字段顺序、缩进、正文、行尾风格全部逐字保留。所以刻意不走
148
+ // `parse → stringify`——那会把用户的文件整体重排(diff 噪声、丢注释),而技能文件是
149
+ // **用户手写的资产**,不是我们的数据。
150
+ // 2. **语义先于文本**:先用宿主那套规则(frontmatterBoolean)算出当前值,已经是要的那个值
151
+ // 就原样返回 changed:false。于是「关掉」= 删掉那一行(缺省即 false),开关不留痕迹。
152
+ // 3. **读不准就抛错**:值跨行、不是布尔、YAML 本身读不动、有宿主拒绝的驼峰键、键写在解析器
153
+ // 认得而逐行扫描认不出的形状里(引号键 / 流式映射)、值带 YAML 锚点——一律拒绝并说清原因;
154
+ // 另外**打开**时若那一行带行尾注释也拒绝(**关掉**不受注释拦阻:整行删除的边界是确定的,
155
+ // 见下方那条「注释随行而去」的用例)。猜一个改法等于改坏用户的文件;说「我做不到」才是诚实的行为。
156
+
157
+ /** frontmatter 字段区里的每一行(含它自己的 `\r`,不含 `\n`)。 */
158
+ function frontmatterLines(raw, span) {
159
+ const lines = []
160
+ let lineStart = span.fieldsStart
161
+ while (lineStart < span.closingStart) {
162
+ const newline = raw.indexOf('\n', lineStart)
163
+ const end = newline < 0 || newline > span.closingStart ? span.closingStart : newline
164
+ lines.push({ start: lineStart, end, text: raw.slice(lineStart, end) })
165
+ lineStart = end + 1
166
+ }
167
+ return lines
168
+ }
169
+
170
+ /** 这一行是不是**顶层**的 `key:`——缩进行属于别的字段或嵌套结构,不算。 */
171
+ function isTopLevelKey(text, key) {
172
+ if (/^[ \t]/.test(text)) return false
173
+ const content = text.endsWith('\r') ? text.slice(0, -1) : text
174
+ const colon = content.indexOf(':')
175
+ return colon >= 0 && content.slice(0, colon).trim() === key
176
+ }
177
+
178
+ /**
179
+ * 把 frontmatter 里的一个顶层布尔字段改成目标值,其余字节原样保留。
180
+ *
181
+ * @param {string} raw - 技能文件全文。
182
+ * @param {string} key - 顶层字段名(本插件用 disable-model-invocation)。
183
+ * @param {boolean} value - 目标值。
184
+ * @returns {{ text: string, changed: boolean }} changed 为 false 时 text 与输入逐字节相同。
185
+ * @throws {Error} 形状读不准时抛错,绝不猜一个改法。
186
+ */
187
+ export function setFrontmatterBoolean(raw, key, value) {
188
+ const parsed = parseSkillFrontmatter(raw)
189
+ if (!parsed.ok) throw new Error(`无法改写 frontmatter:${parsed.message}`)
190
+
191
+ // 宿主对驼峰式旧键是**抛错**(整条技能被跳过,:850-861)。此时补一个规范键只会让两个键并存、
192
+ // 宿主照样跳过——面板不能给出「我改好了」的假象,只能点名让用户自己改。
193
+ for (const [legacy, canonical] of LEGACY_INVOCATION_KEYS) {
194
+ if (canonical === key && Object.hasOwn(parsed.data, legacy)) {
195
+ throw new Error(`frontmatter 里有宿主不支持的驼峰键 "${legacy}"(整条技能会被跳过):请先手工改成 "${canonical}"`)
196
+ }
197
+ }
198
+
199
+ let current
200
+ try {
201
+ current = frontmatterBoolean(parsed.data, key)
202
+ } catch (error) {
203
+ throw new Error(`无法改写 ${key}:${error instanceof Error ? error.message : String(error)}。宿主会因此跳过整条技能,请先手工修正这一行`)
204
+ }
205
+ const desired = value === true
206
+ if ((current === true) === desired) return { text: raw, changed: false }
207
+
208
+ const lines = frontmatterLines(raw, parsed.span)
209
+ const at = lines.findIndex((line) => isTopLevelKey(line.text, key))
210
+ if (at < 0) {
211
+ // ⚠️ 追加的前提是「键**真的**不在」。行扫描认不出的形状是存在的:引号键(`"key": true`)与
212
+ // 流式映射(`{key: true}`)解析器都认得、宿主也读得到(面板照它显示权限),
213
+ // 而 isTopLevelKey 匹配不上那一行。此时追加会写出**第二个**同名键 → YAML 重复键让整份
214
+ // frontmatter 报废(宿主随即跳过整条技能),而用户要的那个方向根本没生效。
215
+ // 所以这里的判据取**解析结果**,不取行扫描结果:两者分叉时只能拒绝。
216
+ if (Object.hasOwn(parsed.data, key)) {
217
+ throw new Error(`无法改写 ${key}:这个键写在解析器认得、逐行改写认不出的形状里(引号键或流式映射),请手工修改`)
218
+ }
219
+ // 键确实不在、又要求打开:追加到 frontmatter 块**末尾**。插在 name/description 之前会把后面
220
+ // 每个字段的 diff 都推一行,而这里没有任何理由去动别人的字段。
221
+ return {
222
+ text: raw.slice(0, parsed.span.closingStart) + `${key}: true` + parsed.span.eol + raw.slice(parsed.span.closingStart),
223
+ changed: true,
224
+ }
225
+ }
226
+
227
+ const line = lines[at]
228
+ const next = lines[at + 1]
229
+ // 续行守卫:下一行只要缩进,值就跨了行——删掉首行会留下孤儿缩进行,整份 frontmatter 随之报废。
230
+ if (next !== undefined && /^[ \t]/.test(next.text)) {
231
+ throw new Error(`无法改写 ${key}:这个字段的值跨了多行(下一行有缩进),逐行改写会破坏 YAML,请手工修改`)
232
+ }
233
+
234
+ const hasCr = line.text.endsWith('\r')
235
+ const content = hasCr ? line.text.slice(0, -1) : line.text
236
+ const colon = content.indexOf(':')
237
+ const rest = content.slice(colon + 1)
238
+ // 锚点守卫:布尔字面量里不可能出现 `&`,所以走到这里还见到它只可能是 YAML 锚点(`key: &flag true`)。
239
+ // 这一行正给别处的 `*flag` 供值——整行删掉或就地换值都会让那个引用悬空、整份 frontmatter 报废。
240
+ // 两个方向都拦,所以放在方向分支之前(判据同样取解析结果那一侧的事实,不猜文本)。
241
+ if (rest.includes('&')) {
242
+ throw new Error(`无法改写 ${key}:这个字段的值带 YAML 锚点(&),别处可能正引用它,请手工修改`)
243
+ }
244
+
245
+ if (desired) {
246
+ // 能走到这里只可能是「写的是 false 那一类」(no / off / 0 / false):就地把值换成 true,
247
+ // 键的位置不动。值是块标量或带行尾注释时不动手——那两种改法都得猜。
248
+ if (rest.trim() === '' || rest.includes('#')) {
249
+ throw new Error(`无法改写 ${key}:这一行不是「键: 简单值」的形状,请手工修改`)
250
+ }
251
+ const replaced = content.slice(0, colon + 1) + ' true' + (hasCr ? '\r' : '')
252
+ return { text: raw.slice(0, line.start) + replaced + raw.slice(line.end), changed: true }
253
+ }
254
+ // 关掉:整行连同它自己的换行一起删掉,其余行与行尾风格不变。
255
+ return { text: raw.slice(0, line.start) + raw.slice(line.end + 1), changed: true }
256
+ }
257
+
118
258
  /**
119
259
  * 诊断一条技能原文,产出可展示的逐条诊断。
120
260
  *
@@ -9,6 +9,7 @@
9
9
  import { access, stat } from 'node:fs/promises'
10
10
  import { homedir } from 'node:os'
11
11
  import { dirname, join, resolve } from 'node:path'
12
+ import { readSkillDocument, resolveSkillTarget, SkillNotFoundError } from './skills-file-ops.js'
12
13
 
13
14
  // 宿主的 source 标签(见 dsh-skill-filesystem 的 roots()):project-* 属项目级,
14
15
  // 其余属全局级。这是唯一可靠的作用域依据——本机就没有 ~/.dsh/skills,技能全在
@@ -110,24 +111,62 @@ async function presetScope(ctx) {
110
111
  }
111
112
  }
112
113
 
114
+ /** 「这个技能读不到」:定位不到,或文件系统层面读不了(已被删、无权限、目录不可达)。 */
115
+ function isUnreadable(error) {
116
+ if (error instanceof SkillNotFoundError) return true
117
+ return typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code)
118
+ }
119
+
120
+ /**
121
+ * 一行的调用权限——**一律来自技能文件本身**,不取宿主的发现缓存。
122
+ *
123
+ * 为什么不能用宿主 summary 里的那份:summary 是**发现缓存**(同一个文件被改写后它继续报旧值,
124
+ * 改名场景已实测复现,见 list() 里那段「陈旧行」的说明)。而调用权限正是本面板要**写**的东西:
125
+ * 写盘后显示回退,用户看到的是「点了没反应」;更糟的是把缓存里的旧值当事实展示。
126
+ * 行与详情因此都从这个文件的字节里解析——它们与用户看到的正文同源。
127
+ *
128
+ * 读不到就是 `null`(不知道):宿主缓存可能指向一个已经被删/搬走的技能,此时「能不能被模型调用」
129
+ * 没有答案,编一个布尔值就是让面板撒谎。
130
+ *
131
+ * @param {{ name: string, root?: string, scope: 'global'|'project' }} row
132
+ * @returns {Promise<{ modelInvocable: boolean|null, userInvocable: boolean|null }>}
133
+ */
134
+ async function invocationOf(row) {
135
+ const unknown = { modelInvocable: null, userInvocable: null }
136
+ if (typeof row?.name !== 'string' || typeof row?.root !== 'string' || row.root === '') return unknown
137
+ try {
138
+ const target = await resolveSkillTarget({ name: row.name, root: row.root, scope: row.scope })
139
+ const { invocation } = await readSkillDocument(target)
140
+ return {
141
+ modelInvocable: invocation?.modelInvocable ?? null,
142
+ userInvocable: invocation?.userInvocable ?? null,
143
+ }
144
+ } catch (error) {
145
+ // 只吞「读不到」这一类;其余错误照原样抛——把 bug 说成「未知」等于把缺陷埋起来。
146
+ if (isUnreadable(error)) return unknown
147
+ throw error
148
+ }
149
+ }
150
+
113
151
  /**
114
152
  * 把宿主的一条 SkillSummary 投影成面板行。
115
153
  * @param {object} summary - 宿主 SkillSummary。
116
154
  * @param {{ cwd?: string }} options - 该条目所属的查询作用域。
117
- * @returns {object} 面板行。
155
+ * @returns {Promise<object>} 面板行。
118
156
  */
119
- function toView(summary, { cwd }) {
120
- const invocation = summary.invocation ?? {}
157
+ async function toView(summary, { cwd }) {
121
158
  const located = locateSkill(summary)
159
+ const scope = scopeOfSource(summary.source)
160
+ // 调用权限**不读** summary.invocation:那一份来自宿主的发现缓存(见 invocationOf 的说明)。
161
+ const invocation = await invocationOf({ name: summary.name, root: located.root, scope })
122
162
  return {
123
163
  name: summary.name,
124
164
  description: summary.description ?? '',
125
165
  ...(summary.whenToUse !== undefined ? { whenToUse: summary.whenToUse } : {}),
126
- modelInvocable: invocation.modelInvocable !== false,
127
- userInvocable: invocation.userInvocable !== false,
166
+ ...invocation,
128
167
  source: summary.source ?? 'unknown',
129
168
  provider: summary.provider ?? 'unknown',
130
- scope: scopeOfSource(summary.source),
169
+ scope,
131
170
  // 根目录与形态推不出来就不填(上层据此提示「无法定位」,而不是作用在错的目录上)。
132
171
  ...(located.root !== undefined ? { root: located.root, kind: located.kind } : {}),
133
172
  ...(cwd !== undefined ? { cwd } : {}),
@@ -155,7 +194,7 @@ async function queryScope(skills, options) {
155
194
  ...(signal === undefined ? {} : { signal }),
156
195
  }
157
196
  const summaries = await skills.list(request)
158
- return summaries.map((summary) => toView(summary, { cwd }))
197
+ return await Promise.all(summaries.map((summary) => toView(summary, { cwd })))
159
198
  }
160
199
 
161
200
  /**
@@ -199,8 +238,8 @@ async function listSkillViews(ctx, options = {}) {
199
238
  *
200
239
  * 为什么删掉而不是留着:`ctx.skills.get(name)` 按 rank 返回**生效者**,与面板
201
240
  * 「(name, root) 精确操作」的模型(D8)冲突——点被遮蔽的那行会读到生效者的正文。
202
- * 现在的 detail 走 `resolveTargetReference` + `resolveSkillTarget` + `readSkillFile`,
203
- * 既精确到根,又能读出已停用技能的 `.disabled` 正文。
241
+ * 现在的 detail 走 `resolveTargetReference` + `resolveSkillTarget` + `readSkillDocument`,
242
+ * 既精确到根,又能读出已停用技能的 `.disabled` 正文(并顺带从同一份字节里给出调用权限)。
204
243
  */
205
244
 
206
245
  // ── 安装目标(skillTargets)───────────────────────────────────────────────
@@ -402,4 +441,4 @@ async function resolveTargetReference(ctx, reference) {
402
441
  */
403
442
  const skillRowKey = (row) => `${row?.source || 'unknown'}\u0000${row?.cwd || ''}\u0000${row?.name || ''}`
404
443
 
405
- export { listSkillViews, queryScope, resolveTargetReference, skillRowKey, skillTargetsView }
444
+ export { invocationOf, listSkillViews, queryScope, resolveTargetReference, skillRowKey, skillTargetsView }
package/lib/typert.js CHANGED
@@ -106,6 +106,7 @@ export const TYPERT = {
106
106
  mvs('list', [skillPayloadParam], 'SkillListResult'),
107
107
  mvs('detail', [skillPayloadParam], 'SkillDetailResult'),
108
108
  mvs('setEnabled', [skillPayloadParam], 'SkillMutationResult'),
109
+ mvs('setModelInvocable', [skillPayloadParam], 'SkillMutationResult'),
109
110
  // ⚠️ 不能叫 `remove`:客户端适配器(dsh-api-gateway 的 RemoteNamespaceService)自己就有原型
110
111
  // 方法 `remove(kind, method, token)`,`ctx.remote.$mount` 会把「与命名空间服务自身成员重名」
111
112
  // 的 endpoint 直接拒掉——报 `client api: method "skillsManager/remove" conflicts with its
package/lib/whats-new.js CHANGED
@@ -184,4 +184,30 @@ export const WHATS_NEW = [
184
184
  },
185
185
  ],
186
186
  },
187
+ {
188
+ version: '1.6.2',
189
+ items: [
190
+ {
191
+ text: '**技能能一键改成「只在你手动点名时可用」**:详情页新增「模型自动调用」开关,直接改 `SKILL.md` 的 `disable-model-invocation`,不必再手工翻文件。关闭时它把那**一行**删掉(缺省即允许,所以不留痕迹)、只在需要时于 frontmatter 末尾追加一行——字段顺序与正文都不动;形状读不准(值跨行,或要在带行尾注释的那一行上打开它)时它会**拒绝**并说明原因,而不是猜一个改法',
192
+ where: '技能 → 选中一条技能 → 概览',
193
+ },
194
+ {
195
+ text: '**调用权限现在如实显示**:以前已停用的技能一律被显示成「模型不可调用 / 用户不可调用」——那是凭空的断言(它的权限只有那份 `.disabled` 文件能回答)。现在按技能文件里真正写着的内容显示,读不出来(frontmatter 非法、宿主会整条跳过它)就显示「未知」而不猜一个值',
196
+ where: '技能列表的徽章与详情里的属性',
197
+ },
198
+ {
199
+ text: '**修好「有新版本却从不提示」**:更新检查原先读仓库的 release feed,而那里唯一的 latest 被另一条线的 pre-release 规则占着,于是本线的提示恒为「已是最新」——1.6.2 发出来你也看不到它。现在问的是本包在 npm 上的最新版。⚠️ 这条修复装在**这一版**里,所以 1.6.1 及更早的用户请手动 `dsh plugin --profile web update dsh-toolbox-ui` 升一次;从这一版起提示恢复正常',
200
+ where: '面板顶部的更新提示条',
201
+ },
202
+ ],
203
+ },
204
+ {
205
+ version: '1.6.3',
206
+ items: [
207
+ {
208
+ text: '**这一版没有功能改动**:改的是门面与发布资产——README 重写(更短、只留两张关键截图)、换上新图标,仓库也从旧名改成了 `dsh-toolbox-ui`,与包名终于对上。旧地址 GitHub 会自动跳转,已有的安装、书签与 `github:` 链接都不受影响',
209
+ where: '仓库主页',
210
+ },
211
+ ],
212
+ },
187
213
  ]
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "dsh-toolbox-ui",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "Responsive MCP management UI and skills manager for DeepSeek Harness Web — floating panel, JSON import, profile-backed Host Remote management, and a skill market with a hard license gate.",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/Imzl-zl/dsh-mcp-manager-ui.git"
7
+ "url": "git+https://github.com/Imzl-zl/dsh-toolbox-ui.git"
8
8
  },
9
- "homepage": "https://github.com/Imzl-zl/dsh-mcp-manager-ui#readme",
9
+ "homepage": "https://github.com/Imzl-zl/dsh-toolbox-ui#readme",
10
10
  "bugs": {
11
- "url": "https://github.com/Imzl-zl/dsh-mcp-manager-ui/issues"
11
+ "url": "https://github.com/Imzl-zl/dsh-toolbox-ui/issues"
12
12
  },
13
13
  "type": "module",
14
14
  "main": "lib/index.js",