dsh-toolbox-ui 1.6.3 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dsh-mcp-manager-ui contributors
3
+ Copyright (c) 2026 dsh-toolbox-ui contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.en.md CHANGED
@@ -57,10 +57,10 @@ See every installed skill's **real scope** and cross-scope shadowing, enable/dis
57
57
 
58
58
  ```sh
59
59
  # Recommended: from npm — `dsh plugin update` then upgrades within ^1.x
60
- dsh plugin --profile web add dsh-toolbox-ui@^1.6.3
60
+ dsh plugin --profile web add dsh-toolbox-ui@^1.6.4
61
61
 
62
62
  # Or pin a GitHub release tag (no automatic upgrades; re-add with a new tag)
63
- dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.3
63
+ dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.4
64
64
  ```
65
65
 
66
66
  Then **restart `dsh web`**. To uninstall (your existing MCP entries stay):
package/README.md CHANGED
@@ -57,10 +57,10 @@
57
57
 
58
58
  ```sh
59
59
  # 推荐:npm 安装 —— 之后 `dsh plugin update` 在 ^1.x 内自动升小版本
60
- dsh plugin --profile web add dsh-toolbox-ui@^1.6.3
60
+ dsh plugin --profile web add dsh-toolbox-ui@^1.6.4
61
61
 
62
62
  # 或固定 GitHub release tag(不自动跨版本,升级要换 tag 重新 add)
63
- dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.3
63
+ dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.4
64
64
  ```
65
65
 
66
66
  装完**重启 `dsh web`**。卸载(不会删除 profile 里已有的 MCP 条目):
@@ -15,14 +15,14 @@
15
15
  推荐用 npm 安装:包里的 `^1.x` 范围让后续 `dsh plugin update` 能自动升级小版本。
16
16
 
17
17
  ```sh
18
- dsh plugin --profile web add dsh-toolbox-ui@^1.6.3
18
+ dsh plugin --profile web add dsh-toolbox-ui@^1.6.4
19
19
  ```
20
20
 
21
21
  也可以固定 GitHub release tag(适合需要锁定某个具体版本时):
22
22
 
23
23
  ```sh
24
24
  # 固定 release tag
25
- dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.3
25
+ dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#v1.6.4
26
26
 
27
27
  # 或固定某个 commit
28
28
  dsh plugin --profile web add github:Imzl-zl/dsh-toolbox-ui#<commit>
@@ -73,7 +73,7 @@ dsh plugin --profile web update dsh-toolbox-ui
73
73
 
74
74
  | 安装规格 | `update` 的效果 | 怎么跨版本 |
75
75
  |---|---|---|
76
- | `dsh-toolbox-ui@^1.6.3`(npm,推荐) | 在 `^1.x` 内自动升到最新小版本,不会跨到 2.0.0 | 已是自动;跨大版本显式安装 `@^2` |
76
+ | `dsh-toolbox-ui@^1.6.4`(npm,推荐) | 在 `^1.x` 内自动升到最新小版本,不会跨到 2.0.0 | 已是自动;跨大版本显式安装 `@^2` |
77
77
  | `github:...#v1.4.0`(tag) | 重新解析同一个 tag,**不会动** | 换成新 tag 重新 `add`:`... #<新 tag>` |
78
78
  | `github:...#<commit>` | 同上,固定在 commit | 用新 commit/tag 重新 `add` |
79
79
  | `github:...#main`(分支) | 拉取最新 main | 已是自动;但不推荐生产(无 release 门槛) |
@@ -113,7 +113,7 @@ dsh web # 打开面板点一遍
113
113
  # 3) 提交并推 tag(CI 会校验 tag 与 version 一致,不一致就拒绝发布)
114
114
  git add -A && git commit -m "..."
115
115
  git push origin main
116
- git tag -a v1.6.3 -m "dsh-toolbox-ui 1.6.3" && git push origin v1.6.3
116
+ git tag -a v1.6.4 -m "dsh-toolbox-ui 1.6.4" && git push origin v1.6.4
117
117
  ```
118
118
 
119
119
  推 tag 后 CI 依次做:校验版本 → `pnpm install --frozen-lockfile` → `npm test` → `npm publish`(trusted publishing,无需 token)→ `gh release create`。
@@ -132,7 +132,7 @@ git tag -a v1.6.3 -m "dsh-toolbox-ui 1.6.3" && git push origin v1.6.3
132
132
 
133
133
  ```sh
134
134
  npm publish # 会弹 2FA 挑战;若用 bypass-2FA 的 granular token 则非交互
135
- gh release create v1.6.3 --title "dsh-toolbox-ui v1.6.3" --notes-file <notes>
135
+ gh release create v1.6.4 --title "dsh-toolbox-ui v1.6.4" --notes-file <notes>
136
136
  ```
137
137
 
138
138
  `npm login` 的会话 token 只有 2 小时,超时需要重新登录;classic token 已被 npm 永久吊销,能非交互发布的只剩勾了 **Bypass 2FA** 的 granular access token——但它正在被 npm 收窄(已不能做账号/包管理类操作,且 2027-01 起将失去直接发布能力),所以长期方案是上面的 trusted publishing。
@@ -150,9 +150,9 @@ dsh plugin --profile web remove dsh-toolbox-ui
150
150
  本地开发才使用目录安装:
151
151
 
152
152
  ```sh
153
- cd C:\sudy\github\dsh-mcp-manager-ui
153
+ cd C:\path\to\dsh-toolbox-ui
154
154
  pnpm install
155
- dsh plugin --profile web add C:\sudy\github\dsh-mcp-manager-ui
155
+ dsh plugin --profile web add C:\path\to\dsh-toolbox-ui
156
156
  ```
157
157
 
158
158
  修改 Host 代码后需要重启 `dsh web`;浏览器 bundle 是否热更新取决于当前 DSH 的 client HMR 状态。
package/docs/skills.md CHANGED
@@ -39,7 +39,15 @@ MCP 面的市场走的是同一个模式的同一种形态(内联网格,不
39
39
  | 500 | `<AGENTS_HOME>/skills` | 全局 |
40
40
  | 600 | 内置(bundled) | **只读**,不可启停/删除 |
41
41
 
42
- 同名技能出现在多个作用域时,面板会标出**哪一份生效、哪一份被遮蔽**——被遮蔽的那份模型收不到,但你确实装了它。⚠️ 边界:宿主按名字只报告**生效**的那一份,所以落选副本只有在以 `SKILL.md.disabled` 形态留下时才看得见;两份都启用时,落选那份目前不在面板数据里(要报它得自己扫各根,见 TODO row 21 旁边的同名副本扫描)。
42
+ 同名技能出现在多个作用域时,面板会标出**哪一份生效、哪一份被遮蔽**——被遮蔽的那份模型收不到,但你确实装了它。生效者按优先级数值**最小**者判定,与它在列表里的位置无关。⚠️ 边界:宿主按名字只报告**生效**的那一份,所以落选副本只有在以 `SKILL.md.disabled` 形态留下时才看得见;两份都启用时,落选那份目前不在面板数据里(要报它得自己扫各根,见 TODO row 21 旁边的同名副本扫描)。
43
+
44
+ ### 同一个目录被两处指到时
45
+
46
+ 优先级表里的两个位置**可能算出来是同一个目录**——最常见的是 `DSH_HOME` 就在工作区里:设 `DSH_HOME=D:\DSH\.dsh`、工作区是 `D:\DSH`(它自己是 git 根),那么项目级 `.dsh/skills` 与全局 `<DSH_HOME>/skills` 是同一个物理目录。
47
+
48
+ 面板按**物理位置**去重:同一个文件只占一行,留优先级更前的那份(也就是模型实际收到的那份);安装目标的候选里同一个目录也只出现一次。反过来,**不同目录**里的同名技能一定各占一行——那是真实的跨作用域遮蔽,合并它会把你真装了的那份藏起来。
49
+
50
+ 同理,技能目录本身是**符号链接**(把它们指向外部同步库是常见做法)时,面板照样能定位到它:宿主报的展示路径与真实路径不一致这件事已经被处理,不会退化成「位置未知」。
43
51
 
44
52
  ## 启用与停用
45
53
 
package/lib/client.js CHANGED
@@ -782,12 +782,26 @@ const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) =
782
782
  * DSH 按 rank 裁决同名技能谁生效,被遮蔽的那份模型收不到——但用户确实装了它。
783
783
  * 不提示的话,用户会以为自己操作的是生效的那份(本仓库的独有价值之一)。
784
784
  *
785
+ * ⚠️ 判据必须是 **rank**,不是数组位置。曾经取「排在最前的那个」当生效者,而那个前提是错的:
786
+ * 宿主 `list` 的输出按**名字**排序(dsh-skill 的 compareSkillSummary),面板自己又按
787
+ * 「全局在前、项目在后」拼接——而项目级 rank(100/200) 恰好**小于**用户级(400/500),
788
+ * 于是"最前的"正好是 rank 最大、也就是真正**落选**的那份,方向完全相反(真机报告 + 已复现)。
789
+ * 结果是面板指着生效的那份说「你被遮蔽了」,用户照它去操作,改的其实是模型收不到的那份。
790
+ *
791
+ * 同名里 rank 最小者胜;rank 相同(同一档的不同项目)时按输入顺序取先者——那种情况下
792
+ * 谁生效由宿主的 providerOrder 决定,面板拿不到,但两个不同项目里的同名技能互不影响,
793
+ * 标一份为胜者只是为了让提示有个主语,不会误导操作。
794
+ *
795
+ * 已停用的行不参与:它的文件是 `SKILL.md.disabled`,模型根本看不到它,说它"遮蔽/被遮蔽"
796
+ * 都是假的。它只该出现在诊断里(见 skillDiagnostics)。
797
+ *
785
798
  * @param {object[]} rows
786
799
  * @returns {object[]} 被遮蔽的行(附生效者信息)。
787
800
  */
788
801
  function shadowedSkills(rows) {
789
802
  const groups = new Map();
790
803
  for (const row of Array.isArray(rows) ? rows : []) {
804
+ if (row?.enabled === false) continue;
791
805
  const list = groups.get(row.name) || [];
792
806
  list.push(row);
793
807
  groups.set(row.name, list);
@@ -795,9 +809,12 @@ function shadowedSkills(rows) {
795
809
  const shadowed = [];
796
810
  for (const [, list] of groups) {
797
811
  if (list.length < 2) continue;
798
- // 生效者是排在最前的那个(宿主已按 rank 排序给出),其余都是被遮蔽的。
799
- const [winner, ...rest] = list;
800
- for (const row of rest) shadowed.push({ ...row, shadowedBy: winner });
812
+ // 生效者 = rank 最小者(同 rank 取先出现的)。不能改用位置,理由见上。
813
+ const winner = list.reduce((best, row) => (sourceRank(row.source) < sourceRank(best.source) ? row : best), list[0]);
814
+ for (const row of list) {
815
+ if (row === winner) continue;
816
+ shadowed.push({ ...row, shadowedBy: winner });
817
+ }
801
818
  }
802
819
  return shadowed;
803
820
  }
@@ -4123,6 +4140,26 @@ const SkillGroupHeader = ({ group, collapsed, onToggleCollapse }) => h(
4123
4140
  h('span', { className: 'dsh-skills-group-count' }, String(group.rows.length)),
4124
4141
  );
4125
4142
 
4143
+ /**
4144
+ * 「调用权限未知」到底是哪种未知——给用户一句**说得对**的原因。
4145
+ *
4146
+ * 为什么不能一律写成「frontmatter 读不出来」:那是把两种不同的事说成了一种,而且真机报告
4147
+ * 证明它会把用户引向错误的方向。技能目录是符号链接时根定位不到(`root === undefined`),
4148
+ * 文件本身完全合法、宿主也照常加载,用户却看到「frontmatter 读不出来(宿主会跳过这条技能)」
4149
+ * ——一句凭空的断言,照它去查 frontmatter 只会白费功夫。
4150
+ *
4151
+ * 这里能分开的是两种(行上有 root 与否就是面板掌握的全部线索):
4152
+ * · 根没定位到 → 面板根本不知道去哪读这个文件(定位问题,不是文件问题);
4153
+ * · 有根却读不出权限 → 文件没了/搬走了/没权限,或 frontmatter 本身有问题。
4154
+ * 第二种没法再细分:读取失败的原因在宿主侧(`invocationOf` 只把结果压成 null),
4155
+ * 面板拿不到更细的话,就不编一个——正文页会把真正的失败原文摆出来。
4156
+ */
4157
+ function invocationUnknownReason(row) {
4158
+ if (typeof row?.root !== 'string' || row.root === '')
4159
+ return '面板没能定位这条技能的目录(技能目录可能是符号链接),因此读不到它的调用权限——文件本身可能是好的';
4160
+ return 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断它的调用权限';
4161
+ }
4162
+
4126
4163
  /** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
4127
4164
  const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
4128
4165
  if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
@@ -4165,7 +4202,7 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4165
4202
  h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
4166
4203
  invocationKnown
4167
4204
  ? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
4168
- : h(Badge, { cls: 's-off', title: 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断它的调用权限' }, '调用权限未知'),
4205
+ : h(Badge, { cls: 's-off', title: invocationUnknownReason(row) }, '调用权限未知'),
4169
4206
  invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
4170
4207
  ),
4171
4208
  h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
@@ -4213,7 +4250,7 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4213
4250
  ? '内置技能只读'
4214
4251
  : invocationKnown
4215
4252
  ? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
4216
- : 'SKILL.md 的 frontmatter 读不出来(宿主会跳过这条技能),无法判断当前状态',
4253
+ : invocationUnknownReason(row),
4217
4254
  onChange: () => onModelInvocable(row, row.modelInvocable !== true),
4218
4255
  }),
4219
4256
  ' ',
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // 本层只读:不写盘、不做启停(那些在 lib/skills-file-ops.js)。
8
8
 
9
- import { access, stat } from 'node:fs/promises'
9
+ import { access, realpath, stat } from 'node:fs/promises'
10
10
  import { homedir } from 'node:os'
11
11
  import { dirname, join, resolve } from 'node:path'
12
12
  import { readSkillDocument, resolveSkillTarget, SkillNotFoundError } from './skills-file-ops.js'
@@ -23,6 +23,36 @@ function scopeOfSource(source) {
23
23
  return PROJECT_SOURCES.has(source) ? 'project' : 'global'
24
24
  }
25
25
 
26
+ /**
27
+ * 宿主六级 source 的 rank(与 provider 的 *_RANK 常量、docs/skills.md 的表一致)。
28
+ * 数值小者优先——这是宿主裁决同名技能的依据,面板的「谁生效」与去重都用它。
29
+ */
30
+ const SKILL_SOURCE_RANK = {
31
+ 'project-dsh': 100,
32
+ 'project-agents': 200,
33
+ custom: 300,
34
+ 'user-dsh': 400,
35
+ 'user-agents': 500,
36
+ bundled: 600,
37
+ }
38
+
39
+ /**
40
+ * 统一分隔符后再比较:宿主给的是**本机原生**路径(Windows 上是反斜杠),而技能根也可能来自
41
+ * 用户配置的 customSkillDirs(两种分隔符都写得出来)。join() 不够用——它是平台相关的,
42
+ * 拿不到「另一种分隔符」的等价形式。只用于**比较与做键**,绝不用于喂给 fs。
43
+ */
44
+ const toSlashes = (value) => String(value).replace(/\\/g, '/')
45
+
46
+ /**
47
+ * 一个**目录**的物理身份(realpath,统一分隔符)。
48
+ *
49
+ * 目录不存在时退回字面值——这时它不可能与别的根重合(都不存在,谁也指不到谁),
50
+ * 而 realpath 会抛 ENOENT:既不能让它把整个列表读失败,也不该为它编一个值。
51
+ */
52
+ async function physicalDirectory(path) {
53
+ return toSlashes(await realpath(path).catch(() => path))
54
+ }
55
+
26
56
  /**
27
57
  * 从宿主 summary 推出技能的**根目录**与**形态**。
28
58
  *
@@ -33,33 +63,58 @@ function scopeOfSource(source) {
33
63
  * 目录捆绑:path = `<技能目录>/SKILL.md`,resourceBase.path = `<技能目录>` → 根 = dirname(base)
34
64
  * 扁平文件:path = `<根>/<name>.md`, resourceBase.path = `<根>` → 根 = base
35
65
  *
36
- * 判别式必须用「path 是否**恰为** `<base>/SKILL.md`」。曾用「dirname(path) === base」判别——
37
- * 那个条件在两种形态下**都成立**(捆绑时 path 的父目录就是 base),不是判别式,会把扁平技能
38
- * 的根算成上一层(实测把 `~/.agents/skills` 算成了 `~/.agents`)。
66
+ * 判别式与「形态不认识就不猜」的纪律见 matchLocator。
67
+ *
68
+ * ⚠️ 两个字段来自**不同的路径口径**,不能只做字面比较(真机缺陷,2026-10-11 复现):
69
+ * `resourceBase.path` = `locator.directory` —— **展示路径**(配置原样,配置写链接就是链接)
70
+ * `path` = `fs.processPath(target)` —— **realpath**(fs-local 里就是 targetKey)
71
+ * 技能目录本身是符号链接/junction 时两者必然分叉,只做字面比较会落进「形态不认识」→ 根推不出来
72
+ * → **整行不可操作**(宿主自己的用例也钉着这个分叉:skill-filesystem.spec.ts 的 linked-dir 那条)。
73
+ * 所以字面没命中时,把 base 规范化到真实路径再判别一次。
74
+ *
75
+ * 返回的 root 一律是**展示路径**:`resolveTargetReference` 要求它与 skillTargetsView 算出的
76
+ * 目标精确相等,返回 realpath 会让所有文件操作对不上(那是更坏的方向)。
39
77
  *
40
78
  * 推不出就返回空:宁可不填,也不猜一个根——猜错会让启停/删除作用在错误的目录上。
41
79
  *
42
80
  * @param {object} summary - 宿主 SkillSummary。
43
- * @returns {{ root?: string, kind?: 'bundle'|'flat' }}
81
+ * @returns {Promise<{ root?: string, kind?: 'bundle'|'flat' }>}
44
82
  */
45
- function locateSkill(summary) {
83
+ async function locateSkill(summary) {
46
84
  const base = summary?.resourceBase?.path
47
85
  const path = summary?.path
48
86
  if (typeof base !== 'string' || base === '' || typeof path !== 'string' || path === '') return {}
49
87
 
50
- // 比较前统一分隔符:宿主给的是**本机原生**路径(Windows 上是反斜杠),而技能根也可能来自
51
- // 用户配置的 customSkillDirs(两种分隔符都写得出来)。join() 在这里不够——它是平台相关的,
52
- // 拿不到「另一种分隔符」的等价形式。返回值仍用**原生** base,不做转换(要给 fs 用)。
53
- const toSlashes = (value) => value.replace(/\\/g, '/')
88
+ // 比较前统一分隔符(见文件上方的 toSlashes 说明)。返回值仍用**原生** base,不做转换(要给 fs 用)。
54
89
  const baseSlashes = toSlashes(base)
55
90
  const pathSlashes = toSlashes(path)
56
91
 
57
- // 判别式:path 是否**恰为** <base>/SKILL.md(目录捆绑)。
58
- // 不能只看 dirname(path) === base —— 那种情况下扁平与捆绑都成立,会把扁平技能的根算成上一层。
59
- if (pathSlashes === `${baseSlashes}/${BUNDLE_FILE}`) return { root: dirname(base), kind: 'bundle' }
60
- if (dirname(pathSlashes) === baseSlashes) return { root: base, kind: 'flat' }
61
- // 形态与两种已知 locator 都不符:不猜。
62
- return {}
92
+ // 先按字面比一次:**没有链接时两个字段本来就同源**,这一步就命中,不必碰磁盘
93
+ // (技能列表每次刷新都要走这里,几十条技能各多一次 realpath 是白花的)。
94
+ const literal = matchLocator(base, baseSlashes, pathSlashes)
95
+ if (literal !== undefined) return literal
96
+
97
+ // 字面没命中:可能是两个字段口径不同(链接目录)。把 base 规范化到真实路径再比一次。
98
+ // 只在**这一条**上付 realpath 的代价,而且这一步同时兜住 Windows 上 realpath 会
99
+ // 规范化盘符/目录名大小写的情形——那种情况下字面同样不相等,但物理上是同一个目录。
100
+ const physical = await physicalDirectory(base)
101
+ if (physical === baseSlashes) return {} // 规范化前后一样,说明字面失败不是口径问题:不猜
102
+ return matchLocator(base, physical, pathSlashes) ?? {}
103
+ }
104
+
105
+ /**
106
+ * 按给定的 base 形态判别 locator(宿主 discoverRoot 的两种形状)。
107
+ *
108
+ * 判别式必须是「path 是否**恰为** `<base>/SKILL.md`」。曾用「dirname(path) === base」判别——
109
+ * 那个条件在两种形态下**都成立**(捆绑时 path 的父目录就是 base),不是判别式,会把扁平技能
110
+ * 的根算成上一层(实测把 `~/.agents/skills` 算成了 `~/.agents`)。
111
+ *
112
+ * @returns {{ root: string, kind: 'bundle'|'flat' } | undefined} 认不出时返回 undefined。
113
+ */
114
+ function matchLocator(base, baseCompared, pathSlashes) {
115
+ if (pathSlashes === `${baseCompared}/${BUNDLE_FILE}`) return { root: dirname(base), kind: 'bundle' }
116
+ if (dirname(pathSlashes) === baseCompared) return { root: base, kind: 'flat' }
117
+ return undefined
63
118
  }
64
119
 
65
120
  /** 取宿主 skills 服务;宿主的服务名就是 'skills'(dsh-skill 的 super(ctx, "skills"))。 */
@@ -155,7 +210,7 @@ async function invocationOf(row) {
155
210
  * @returns {Promise<object>} 面板行。
156
211
  */
157
212
  async function toView(summary, { cwd }) {
158
- const located = locateSkill(summary)
213
+ const located = await locateSkill(summary)
159
214
  const scope = scopeOfSource(summary.source)
160
215
  // 调用权限**不读** summary.invocation:那一份来自宿主的发现缓存(见 invocationOf 的说明)。
161
216
  const invocation = await invocationOf({ name: summary.name, root: located.root, scope })
@@ -205,6 +260,16 @@ async function queryScope(skills, options) {
205
260
  * 进 cwd 分支,而用户根与 bundled 根的 push 在该分支之外无条件执行)。所以项目查询的
206
261
  * 结果已经包含全局技能,直接 concat 会让每个全局技能在每个项目里各重复一份。
207
262
  *
263
+ * ⚠️ 但「按 source 过滤」只挡住了**同一根被两种查询各报一次**这一种重复。还有第二种:
264
+ * 两个根算出来是**同一个物理目录**。真机形状(issue #5,2026-10-11 复现):`DSH_HOME=D:\DSH\.dsh`
265
+ * 而工作区 `D:\DSH` 本身是 git 根,于是 project-dsh 根 = `<DSH_HOME>/skills` = user-dsh 根;
266
+ * 实测同一个文件被列成 N+1 行(全局 1 份 + 每个工作区 1 份),名字相同 → 面板互判「被遮蔽」,
267
+ * 安装下拉里同一个目录也以两个身份各出现一次。
268
+ *
269
+ * 所以去重判据必须是**物理位置**,不是字符串也不只是 source:同一个文件只留一行,
270
+ * 留 rank 更前的那份(= 模型真正会收到的那份)。反过来,**不同文件**的同名技能必须保留
271
+ * 两行——那是真实的跨作用域遮蔽,压掉它等于把用户装的东西藏起来(看 docs/skills.md 的作用域表)。
272
+ *
208
273
  * @param {object} ctx - 宿主上下文(或 { get } 替身)。
209
274
  * @param {{ workspaces?: string[], signal?: AbortSignal }} options
210
275
  * @returns {Promise<object[]>}
@@ -227,12 +292,66 @@ async function listSkillViews(ctx, options = {}) {
227
292
  // 那份同名技能误当成重复而丢掉。
228
293
  return rows.filter((row) => row.scope === 'project')
229
294
  }))
230
- return [...globalRows, ...projectGroups.flat()]
295
+ return dedupeByPhysicalLocation([...globalRows, ...projectGroups.flat()])
231
296
  } finally {
232
297
  await lease.dispose()
233
298
  }
234
299
  }
235
300
 
301
+ /**
302
+ * 同一个**物理文件**只留一行(rank 更前者胜)。
303
+ *
304
+ * 身份用行上的 `path`——那正是宿主给的 realpath(`fs.processPath`),所以链接与真实目录
305
+ * 会归一到同一个键。拿不到 path 的行(旧宿主/替身)退回它的字面身份:宁可少去重一次,
306
+ * 也不能把两行不同的技能误判成同一个。
307
+ *
308
+ * 为什么保留 rank 更前者:那一份才是宿主的胜出条目,也就是模型真正收到的那份;面板的
309
+ * 「谁生效」与文件操作都该指向它。落选的那一行是**同一个文件**(不是副本),留着只会让
310
+ * 用户看到两个都写着"已启用"、还互相说被遮蔽的行。
311
+ *
312
+ * @param {object[]} rows
313
+ * @returns {object[]} 去重后的行,保持输入顺序(全局在前、项目在后)。
314
+ */
315
+ function dedupeByPhysicalLocation(rows) {
316
+ const winnerAt = new Map()
317
+ const result = []
318
+ for (const row of rows) {
319
+ const key = physicalKeyOf(row)
320
+ const seen = winnerAt.get(key)
321
+ if (seen === undefined) {
322
+ winnerAt.set(key, result.length)
323
+ result.push(row)
324
+ continue
325
+ }
326
+ // 同一物理文件:rank 更前(数值更小)的胜出,替换掉先前那份但**留在原位**。
327
+ if (rankOfSource(row.source) < rankOfSource(result[seen].source)) result[seen] = row
328
+ }
329
+ return result
330
+ }
331
+
332
+ /**
333
+ * 一行的**物理身份**:技能文件的真实路径(宿主给的 `path` 已经是 realpath)**+ 技能名**。
334
+ *
335
+ * 为什么必须带上名字:去重的意图是「同一个物理技能被两个根各报了一次」。而技能名来自
336
+ * 文件自己的 frontmatter——同一个文件只可能有一条技能,所以「同路径 + 同名」才是同一个技能。
337
+ * 只看路径会把「同路径但不同名」的两行也压掉,而那两行要么是替身数据不严谨,要么是
338
+ * 宿主行为出了别的问题;无论哪种,静默吞掉一行都比留着它更难查。
339
+ *
340
+ * 拿不到 path 时退回**行的完整身份**(source + cwd + name,与 skillRowKey 同一套):
341
+ * 那种情况下物理位置无从得知,而「不同项目里的同名技能」必须各占一行(它们真的是两份)。
342
+ */
343
+ function physicalKeyOf(row) {
344
+ if (typeof row?.path === 'string' && row.path !== '') {
345
+ return `path\u0000${toSlashes(row.path)}\u0000${row?.name ?? ''}`
346
+ }
347
+ return `row\u0000${row?.source ?? ''}\u0000${row?.cwd ?? ''}\u0000${row?.name ?? ''}`
348
+ }
349
+
350
+ /** source → 宿主 rank(数值小者优先);表外的来源排在最后。 */
351
+ function rankOfSource(source) {
352
+ return SKILL_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER
353
+ }
354
+
236
355
  /**
237
356
  * 加载单个技能正文的旧路径:**已被 detail 的按根读取取代**(2026-10-10)。
238
357
  *
@@ -259,15 +378,6 @@ async function listSkillViews(ctx, options = {}) {
259
378
  // 3) `findProjectRoot` 照抄 provider 的实现:往上找含 `.git` 的目录,找不到就用原路径。
260
379
  // ⚠️ provider 改算法时这里要一起改;语义由 test/skill-targets.test.mjs 钉住。
261
380
 
262
- /** 宿主的 source 档位与 rank(与 provider 的 *_RANK 常量、docs/skills.md 的表一致)。 */
263
- const SKILL_SOURCE_RANK = {
264
- 'project-dsh': 100,
265
- 'project-agents': 200,
266
- custom: 300,
267
- 'user-dsh': 400,
268
- 'user-agents': 500,
269
- bundled: 600,
270
- }
271
381
  const DSH_HOME_ENV = 'DSH_HOME'
272
382
  const AGENTS_HOME_ENV = 'DSH_AGENTS_HOME'
273
383
  const BUNDLED_SKILL_DIR_ENV = 'DSH_BUNDLED_SKILL_DIR'
@@ -390,8 +500,40 @@ async function skillTargetsView(ctx, options = {}) {
390
500
  }
391
501
  if (typeof bundledDir === 'string' && bundledDir !== '') await push('bundled', resolve(bundledDir))
392
502
 
393
- targets.sort((left, right) => left.rank - right.rank || left.root.localeCompare(right.root))
394
- return { targets }
503
+ // 同一个**物理目录**只列一次:DSH_HOME 与工作区重合时,project-dsh 与 user-dsh 算出来是
504
+ // 同一个目录(真机形状见 listSkillViews)。留着会让安装下拉里同一个位置出现两次,
505
+ // 用户以为有两个可选的落点。
506
+ //
507
+ // ⚠️ 在**排序之前**去重,并且留 rank 更前的那份:rank 决定宿主优先采纳哪个根,
508
+ // 而 `targets[0]` 正是安装弹窗的默认选项(见客户端的 installTargets)——留错那份会让
509
+ // 默认落点指向一个"名字对了、身份不对"的位置。
510
+ const unique = await dedupeTargetsByPhysicalLocation(targets)
511
+ unique.sort((left, right) => left.rank - right.rank || left.root.localeCompare(right.root))
512
+ return { targets: unique }
513
+ }
514
+
515
+ /**
516
+ * 同一个物理目录的目标只留一个(rank 更前者胜)。
517
+ *
518
+ * 为什么单独一步而不是并进 push:`exists` 是**观察**(要问磁盘),去重是**身份**判断,
519
+ * 混在一处会让「先问盘再决定收不收」这种顺序依赖悄悄长出来。
520
+ */
521
+ async function dedupeTargetsByPhysicalLocation(targets) {
522
+ const seenPhysical = new Map()
523
+ const result = []
524
+ for (const target of targets) {
525
+ const physical = await physicalDirectory(target.root)
526
+ const previous = seenPhysical.get(physical)
527
+ if (previous === undefined) {
528
+ seenPhysical.set(physical, result.length)
529
+ result.push(target)
530
+ continue
531
+ }
532
+ // 同一物理目录:rank 更前(数值更小)的胜出,就地替换先前那份(索引不变,
533
+ // 顺序由后面的排序定)。
534
+ if (target.rank < result[previous].rank) result[previous] = target
535
+ }
536
+ return result
395
537
  }
396
538
 
397
539
  /**
package/lib/whats-new.js CHANGED
@@ -210,4 +210,21 @@ export const WHATS_NEW = [
210
210
  },
211
211
  ],
212
212
  },
213
+ {
214
+ version: '1.6.4',
215
+ items: [
216
+ {
217
+ text: '**技能目录是符号链接时不再「整行不可用」**:这种情况下面板以前显示「位置(未报告)/调用权限未知」,点开还读不出正文——悬浮提示把它说成 frontmatter 有问题,其实是面板没能定位到那个目录(文件一直是好的)。现在链接目录照常定位,位置与调用权限都正常显示,启停、删除、读正文也都可用',
218
+ where: '技能列表与详情',
219
+ },
220
+ {
221
+ text: '**同一个目录被两处指到时不再重复列出**:当 `DSH_HOME` 就在工作区里(例如 `DSH_HOME=D:\\DSH\\.dsh` 而工作区是 `D:\\DSH`),项目级与全局的技能目录其实是同一个。以前同一个技能会被列成好几行、还互相标「被遮蔽」,安装位置里同一个目录也出现两次。现在同一个文件只占一行(留优先级更高的那份,也就是模型实际收到的那份);**不同目录**里的同名技能仍然各占一行——那是真实的遮蔽,不是重复',
222
+ where: '技能列表、安装到',
223
+ },
224
+ {
225
+ text: '**「哪一份生效」以前说反了**:同名技能里,面板指着的其实是**模型收不到**的那份,于是你可能照着提示去操作了另一份。现在按 DSH 的优先级如实判定(项目级优先于全局),并说明生效的那份在哪',
226
+ where: '技能列表的「被遮蔽」徽章与诊断页',
227
+ },
228
+ ],
229
+ },
213
230
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-toolbox-ui",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
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",