@michengai/dsh-skills-manager 0.1.32 → 0.1.33

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 CHANGED
@@ -6,6 +6,14 @@ The five most recent published versions are listed below.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.1.33 — 2026-09-02
10
+
11
+ - Added an enabled-by-default, read-only `CC Switch` source at `~\.cc-switch\skills` with source-wide and per-Skill local policy toggles. Legacy state files gain the new source without failing closed.
12
+ - Allowed top-level linked Skill bundles in user-level read-only sources only when they target a direct child of another known read-only Skills root. Provider loading revalidates the real target and deduplicates CC Switch deployments by real path.
13
+ - Kept writable DSH roots, project sources, linked roots, hidden or invalid targets, arbitrary external targets, and import symlinks rejected.
14
+
15
+ Published package: [`@michengai/dsh-skills-manager@0.1.33`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.33).
16
+
9
17
  ## 0.1.32 — 2026-08-31
10
18
 
11
19
  - Unified every per-Skill enable/disable action—user DSH, shared Agents, Codex, Claude, Gemini, OpenCode, project DSH, and project Agents—under manager-local tri-state policy; toggles never rewrite a source Skill file.
@@ -6,6 +6,14 @@
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## 0.1.33 — 2026-09-02
10
+
11
+ - 新增默认开启的只读 `CC Switch` 来源,固定读取 `~\.cc-switch\skills`,支持来源级与逐 Skill 本地启停;旧状态文件自动补齐新来源,不会因缺少新键失败关闭。
12
+ - 用户级只读来源支持指向已知只读 Skills 根直接子目录的顶层技能链接;Provider 在加载时复验真实目标,并按真实路径去重 CC Switch 分发到 Codex、Claude 等目录的副本。
13
+ - 可写 DSH、项目来源、技能根链接、隐藏或非法目标以及任意根外目标继续拒绝,导入符号链接边界不变。
14
+
15
+ 发布包:[`@michengai/dsh-skills-manager@0.1.33`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.33)。
16
+
9
17
  ## 0.1.32 — 2026-08-31
10
18
 
11
19
  - 将用户 DSH、公共 Agent、Codex、Claude、Gemini、OpenCode、项目 DSH 与项目 Agent 的逐 Skill 启停全部统一为 manager 本地三态策略;启停不再改写任何来源 Skill 文件。
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  ## Features
24
24
 
25
- - Discover and load user-level skills from `.agents`, Codex, Claude, Gemini, and OpenCode into DSH.
25
+ - Discover and load user-level skills from `.agents`, CC Switch, Codex, Claude, Gemini, and OpenCode into DSH.
26
26
  - Discover project-level `.dsh/skills` and `.agents/skills` from active Session workspaces, grouped by project; every valid Skill can be enabled or disabled through non-mutating local policy.
27
27
  - Persist every per-Skill toggle under `$DSH_HOME/skills-manager/state.json` without rewriting any source Skill file.
28
28
  - Inspect Markdown bodies, frontmatter, invocation state, duplicate shadowing, and format diagnostics in a source-first UI.
@@ -152,6 +152,7 @@ Escape closes only the frontmost upload or confirmation dialog and leaves Settin
152
152
  | --- | --- | --- | --- | --- |
153
153
  | `$DSH_HOME\skills` | Yes | Manager state only | Yes | Moves to Trash |
154
154
  | `$DSH_AGENTS_HOME\skills` | Yes | Manager state only | No | No |
155
+ | `~\.cc-switch\skills` | Yes, enabled by default | Manager state only | No | No |
155
156
  | `~/.codex/skills`, `~/.claude/skills`, `~/.gemini/skills`, `~/.config/opencode/skills` | Yes | Manager state only | No | No |
156
157
  | `<project>/.dsh/skills` | Yes, for active Session workspaces | Manager state only | Create in Settings | Moves to Trash and restores to the original project |
157
158
  | `<project>/.agents/skills` | Yes, for active Session workspaces | Manager state only | No | No |
@@ -162,6 +163,7 @@ Escape closes only the frontmost upload or confirmation dialog and leaves Settin
162
163
  - Trash falls back to copy-then-hide when a project and `$DSH_HOME` are on different volumes; restore uses the same guarded cross-volume path in reverse.
163
164
  - Project Trash entries retain their original opaque source identity. Restore is allowed only while that original project is still represented by an active Session workspace; the client cannot nominate a replacement path.
164
165
  - Project writes reject linked `.dsh` or `.dsh/skills` directories so a repository cannot redirect creation, deletion, or restore outside its own project root.
166
+ - User-level read-only sources accept top-level linked Skill bundles only when the real target is an ordinary direct child of another known read-only Skills root. The same real Skill is displayed and loaded once according to source rank; writable DSH roots, project roots, linked roots, and arbitrary external targets remain rejected.
165
167
  - Rows and summaries say **Enabled/Disabled**, not **Loaded**: these labels describe invocation policy, while full Skill bodies are loaded on demand by DSH. Use Refresh after IDE, Git, or shell changes; the official provider remains responsible for project catalog watching and invalidation.
166
168
  - Empty project roots stay out of the main source list to reduce noise, but remain selectable in Create Skill so the first project Skill can still be created. Project DSH supports per-Skill toggles only, not a source-wide switch.
167
169
  - Replacements copy to a temporary sibling path first and keep the original until that succeeds.
package/README.zh-CN.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  ## 功能概览
24
24
 
25
- - 自动发现并把 `.agents`、Codex、Claude、Gemini 和 OpenCode 的用户级技能真正加载进 DSH。
25
+ - 自动发现并把 `.agents`、CC Switch、Codex、Claude、Gemini 和 OpenCode 的用户级技能真正加载进 DSH。
26
26
  - 从活动 Session 工作区发现项目级 `.dsh/skills` 与 `.agents/skills` 并按项目分组;所有有效 Skill 都可通过不改源文件的本地策略启停。
27
27
  - 所有逐 Skill 启停都只写入 `$DSH_HOME\skills-manager\state.json`,绝不改写任何来源 Skill 文件。
28
28
  - 按来源折叠、搜索和筛选,并查看技能正文、frontmatter、调用状态、重名遮蔽与格式诊断。
@@ -152,6 +152,7 @@ dsh --profile web --dump-config
152
152
  | --- | --- | --- | --- | --- |
153
153
  | `$DSH_HOME\skills` | 支持 | 仅写 manager 状态 | 支持 | 进入回收站 |
154
154
  | `$DSH_AGENTS_HOME\skills` | 支持 | 仅写 manager 状态 | 不支持 | 不支持 |
155
+ | `~\.cc-switch\skills` | 支持,默认开启 | 仅写 manager 状态 | 不支持 | 不支持 |
155
156
  | `~/.codex/skills`、`~/.claude/skills`、`~/.gemini/skills`、`~/.config/opencode/skills` | 支持 | 仅写 manager 状态 | 不支持 | 不支持 |
156
157
  | `<project>/.dsh/skills` | 支持活动 Session 工作区 | 仅写 manager 状态 | 设置页支持创建 | 进入回收站并恢复到原项目 |
157
158
  | `<project>/.agents/skills` | 支持活动 Session 工作区 | 仅写 manager 状态 | 不支持 | 不支持 |
@@ -162,6 +163,7 @@ dsh --profile web --dump-config
162
163
  - 当项目与 `$DSH_HOME` 位于不同磁盘时,回收站会降级为“复制后在源盘原子隐藏”;恢复使用反向的同一安全流程。
163
164
  - 项目回收站条目保存原始不透明来源身份。仅当原项目仍由活动 Session 工作区提供时才允许恢复;客户端不能指定替代路径。
164
165
  - 项目写入会拒绝链接形式的 `.dsh` 或 `.dsh/skills` 目录,避免仓库把创建、删除或恢复重定向到项目根之外。
166
+ - 用户级只读来源允许顶层技能目录链接,但真实目标必须是另一个已知只读 Skills 根的普通直接子目录;同一真实技能按来源优先级只展示和加载一次。可写 DSH、项目来源、技能根链接和任意外部目标仍被拒绝。
165
167
  - 列表和摘要统一使用“已启用/已停用”,不再使用“已加载”:这里描述的是调用策略,完整 Skill 正文仍由 DSH 按需加载。IDE、Git 或 shell 改动后可点击“刷新”;项目 catalog 的 watcher 与 invalidation 仍由官方 provider 负责。
166
168
  - 没有 Skill 的项目根不会出现在主来源列表中,但仍保留在“创建技能”的目标选择中,确保可以创建第一个项目 Skill。项目 DSH 只提供逐 Skill 启停,不提供整个项目来源总开关。
167
169
  - 覆盖前先复制到同目录临时路径;复制成功前不会改动现有技能。
package/lib/client.js CHANGED
@@ -35,7 +35,7 @@ window.__ModuleLoader__.load({
35
35
  "error.proto.forbidden": "禁止的修改请求(缺少客户端标记)", "error.proto.forbiddenHost": "禁止的请求来源(非法 Host)", "error.proto.contentType": "请求体必须是 application/json", "error.proto.method": "不支持的请求方法", "error.proto.unknownAction": "未知操作", "error.proto.bodyTooLarge": "请求体过大", "error.proto.invalidJson": "请求体不是合法 JSON", "error.proto.nonJson": "服务端返回非 JSON 响应(HTTP {status})",
36
36
  "diagnostic.frontmatter.missing": "缺少完整 YAML frontmatter", "diagnostic.name.missing": "frontmatter 缺少 name", "diagnostic.name.invalid": "技能名称不是合法 kebab-case:{name}", "diagnostic.description.missing": "frontmatter 缺少 description", "diagnostic.invocation.invalid": "调用策略字段值无效", "diagnostic.shadowed": "被更高优先级来源 {root} 覆盖",
37
37
  "action.enable": "启用", "action.disable": "停用", "action.create": "创建", "action.delete": "删除", "action.restore": "恢复", "action.toggle": "启用或停用",
38
- "root.dsh": "DSH 技能", "root.agents": "公共 Agent", "root.projectDsh": "项目 DSH", "root.projectAgents": "项目 Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
38
+ "root.dsh": "DSH 技能", "root.agents": "公共 Agent", "root.ccswitch": "CC Switch", "root.projectDsh": "项目 DSH", "root.projectAgents": "项目 Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
39
39
  },
40
40
  en: {
41
41
  "title": "Skills", "desc": "Load and manage Agent Skills on this computer in one place.",
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
62
62
  "error.proto.forbidden": "Forbidden mutation request (missing client marker)", "error.proto.forbiddenHost": "Forbidden request origin (invalid host)", "error.proto.contentType": "Content type must be application/json", "error.proto.method": "Method not allowed", "error.proto.unknownAction": "Unknown action", "error.proto.bodyTooLarge": "Request body too large", "error.proto.invalidJson": "Invalid JSON request body", "error.proto.nonJson": "Server returned a non-JSON response (HTTP {status})",
63
63
  "diagnostic.frontmatter.missing": "Missing complete YAML frontmatter", "diagnostic.name.missing": "Frontmatter is missing name", "diagnostic.name.invalid": "Skill name is not valid kebab-case: {name}", "diagnostic.description.missing": "Frontmatter is missing description", "diagnostic.invocation.invalid": "Invocation policy value is invalid", "diagnostic.shadowed": "Shadowed by higher-priority source {root}",
64
64
  "action.enable": "enable", "action.disable": "disable", "action.create": "create", "action.delete": "delete", "action.restore": "restore", "action.toggle": "enabling or disabling",
65
- "root.dsh": "DSH skills", "root.agents": "Shared Agent", "root.projectDsh": "Project DSH", "root.projectAgents": "Project Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
65
+ "root.dsh": "DSH skills", "root.agents": "Shared Agent", "root.ccswitch": "CC Switch", "root.projectDsh": "Project DSH", "root.projectAgents": "Project Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
66
66
  }
67
67
  };
68
68
 
package/lib/core.js CHANGED
@@ -68,6 +68,7 @@ export function userRoots() {
68
68
  return [
69
69
  { key: "dsh", path: join(resolveDshHome(), "skills"), label: "DSH 技能", mutable: true, toggleable: true, native: true, rank: 400 },
70
70
  { key: "agents", path: join(resolveAgentsHome(), "skills"), label: "公共 Agent", mutable: false, toggleable: true, native: true, rank: 450 },
71
+ { key: "ccswitch", path: join(homedir(), ".cc-switch", "skills"), label: "CC Switch", mutable: false, toggleable: true, native: false, rank: 510 },
71
72
  { key: "codex", path: join(process.env.DSH_CODEX_HOME || join(homedir(), ".codex"), "skills"), label: "Codex", mutable: false, toggleable: true, native: false, rank: 520 },
72
73
  { key: "claude", path: join(process.env.DSH_CLAUDE_HOME || join(homedir(), ".claude"), "skills"), label: "Claude", mutable: false, toggleable: true, native: false, rank: 530 },
73
74
  { key: "gemini", path: join(process.env.DSH_GEMINI_HOME || join(homedir(), ".gemini"), "skills"), label: "Gemini", mutable: false, toggleable: true, native: false, rank: 540 },
@@ -336,6 +337,11 @@ async function isInsideResolvedRoot(rootReal, path) {
336
337
  return isSameOrDescendant(rootReal, await resolvedPath(path));
337
338
  }
338
339
 
340
+ function pathIdentity(path) {
341
+ const canonical = resolve(path);
342
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
343
+ }
344
+
339
345
  async function lstatOrNull(path) {
340
346
  try {
341
347
  return await fs.lstat(path);
@@ -344,6 +350,49 @@ async function lstatOrNull(path) {
344
350
  }
345
351
  }
346
352
 
353
+ /**
354
+ * 只接受用户级只读来源中的顶层目录链接,且目标必须是另一个已知只读 Skills 根的直接子目录。
355
+ * 这样可以兼容 CC Switch 的分发链接,同时不把任意文件系统路径扩展成技能来源。
356
+ */
357
+ async function resolveTrustedLinkedBundle(root, bundlePath) {
358
+ const sourceRoot = rootDefinition(root);
359
+ if (!sourceRoot || sourceRoot.mutable || sourceRoot.scope === "project") return null;
360
+
361
+ let realEntryPath;
362
+ try {
363
+ realEntryPath = await fs.realpath(bundlePath);
364
+ } catch {
365
+ return null;
366
+ }
367
+ const realEntryStat = await lstatOrNull(realEntryPath);
368
+ if (!realEntryStat || !realEntryStat.isDirectory() || realEntryStat.isSymbolicLink()) return null;
369
+
370
+ let sourceRootReal;
371
+ try {
372
+ sourceRootReal = await fs.realpath(sourceRoot.path);
373
+ } catch {
374
+ return null;
375
+ }
376
+ let trusted = false;
377
+ for (const targetRoot of userRoots().filter((candidate) => !candidate.mutable && candidate.scope !== "project")) {
378
+ const targetRootStat = await lstatOrNull(targetRoot.path);
379
+ if (!targetRootStat || !targetRootStat.isDirectory() || targetRootStat.isSymbolicLink()) continue;
380
+ const targetRootReal = await fs.realpath(targetRoot.path);
381
+ if (pathIdentity(targetRootReal) === pathIdentity(sourceRootReal)) continue;
382
+ const acceptedTarget = entryPath(targetRootReal, basename(realEntryPath));
383
+ if (acceptedTarget && pathIdentity(acceptedTarget) === pathIdentity(realEntryPath)) {
384
+ trusted = true;
385
+ break;
386
+ }
387
+ }
388
+ if (!trusted) return null;
389
+
390
+ const realDocPath = join(realEntryPath, "SKILL.md");
391
+ const docStat = await lstatOrNull(realDocPath);
392
+ if (!docStat || !docStat.isFile() || docStat.isSymbolicLink()) return null;
393
+ return { realEntryPath, realDocPath };
394
+ }
395
+
347
396
  /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
348
397
  export function entryPath(root, name) {
349
398
  if (typeof name !== "string" || name === "" || name === "." || name === ".." || name.startsWith(".") || name.length > MAX_ENTRY_NAME_LENGTH || /[\\/:*?"<>|\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
@@ -535,14 +584,19 @@ export async function resolveEntry(root, name) {
535
584
  const bundleDoc = join(bundlePath, "SKILL.md");
536
585
  const docStat = await lstatOrNull(bundleDoc);
537
586
  if (docStat && docStat.isFile() && !docStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, bundleDoc)) {
538
- return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath };
587
+ return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath, realDocPath: await fs.realpath(bundleDoc), realEntryPath: await fs.realpath(bundlePath), linked: false };
539
588
  }
540
589
  }
590
+ if (bundleStat && bundleStat.isSymbolicLink()) {
591
+ const linked = await resolveTrustedLinkedBundle(rootPath, bundlePath);
592
+ if (linked) return { kind: "bundle", docPath: join(bundlePath, "SKILL.md"), entryPath: bundlePath, ...linked, linked: true };
593
+ }
541
594
  const flatDoc = resolve(rootPath, `${name}.md`);
542
595
  if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
543
596
  const flatStat = await lstatOrNull(flatDoc);
544
597
  if (flatStat && flatStat.isFile() && !flatStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, flatDoc)) {
545
- return { kind: "flat", docPath: flatDoc, entryPath: flatDoc };
598
+ const realDocPath = await fs.realpath(flatDoc);
599
+ return { kind: "flat", docPath: flatDoc, entryPath: flatDoc, realDocPath, realEntryPath: realDocPath, linked: false };
546
600
  }
547
601
  return null;
548
602
  }
@@ -591,20 +645,29 @@ export async function scanEntries(root) {
591
645
  const rootReal = await resolvedPath(root);
592
646
  for (const it of items) {
593
647
  try {
594
- if (it.isSymbolicLink()) continue;
648
+ if (it.isSymbolicLink()) {
649
+ const bundlePath = entryPath(root, it.name);
650
+ if (bundlePath === null) continue;
651
+ const linked = await resolveTrustedLinkedBundle(root, bundlePath);
652
+ if (!linked) continue;
653
+ const doc = parseSkillDoc(await fs.readFile(linked.realDocPath, "utf8"));
654
+ byName.set(it.name, { ...entryOf(it.name, "bundle", join(bundlePath, "SKILL.md"), doc), entryPath: bundlePath, ...linked, linked: true });
655
+ continue;
656
+ }
595
657
  if (it.isDirectory() && entryPath(root, it.name) !== null) {
596
658
  const docPath = join(root, it.name, "SKILL.md");
597
659
  const st = await lstatOrNull(docPath);
598
660
  if (!st || !st.isFile() || st.isSymbolicLink() || !(await isInsideResolvedRoot(rootReal, docPath))) continue;
599
661
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
600
- byName.set(it.name, entryOf(it.name, "bundle", docPath, doc));
662
+ byName.set(it.name, { ...entryOf(it.name, "bundle", docPath, doc), entryPath: join(root, it.name), realDocPath: await fs.realpath(docPath), realEntryPath: await fs.realpath(join(root, it.name)), linked: false });
601
663
  } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md" && entryPath(root, it.name.slice(0, -3)) !== null) {
602
664
  const skillName = it.name.slice(0, -3);
603
665
  if (byName.has(skillName)) continue;
604
666
  const docPath = join(root, it.name);
605
667
  if (!(await isInsideResolvedRoot(rootReal, docPath))) continue;
606
668
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
607
- byName.set(skillName, entryOf(skillName, "flat", docPath, doc));
669
+ const realDocPath = await fs.realpath(docPath);
670
+ byName.set(skillName, { ...entryOf(skillName, "flat", docPath, doc), entryPath: docPath, realDocPath, realEntryPath: realDocPath, linked: false });
608
671
  }
609
672
  } catch {
610
673
  /* 跳过不可读条目 */
@@ -614,6 +677,23 @@ export async function scanEntries(root) {
614
677
  return { exists: true, entries };
615
678
  }
616
679
 
680
+ /** 同一真实技能可能被 CC Switch 分发到多个 Agent 根;按来源 rank 只保留一份。 */
681
+ async function scanDeduplicatedUserRoots(roots = userRoots()) {
682
+ const scans = new Map();
683
+ for (const root of roots) scans.set(root.key, await scanEntries(root.path));
684
+ const claimed = new Set();
685
+ for (const root of [...roots].sort((left, right) => left.rank - right.rank)) {
686
+ const scan = scans.get(root.key);
687
+ scan.entries = scan.entries.filter((entry) => {
688
+ const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
689
+ if (claimed.has(identity)) return false;
690
+ claimed.add(identity);
691
+ return true;
692
+ });
693
+ }
694
+ return scans;
695
+ }
696
+
617
697
  // ── Manager 本地策略(外部源只读,启停状态写入 DSH_HOME)────────────────────
618
698
 
619
699
  function defaultManagerState() {
@@ -667,6 +747,15 @@ function validManagerStateDocument(value) {
667
747
  return true;
668
748
  }
669
749
 
750
+ /** v0.1.32 及更早状态尚无 CC Switch 键;只补充新增字段,其余损坏仍然失败关闭。 */
751
+ function migrateManagerStateDocument(value) {
752
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1) return value;
753
+ if (value.sources && typeof value.sources === "object" && !Array.isArray(value.sources) && value.sources.ccswitch === undefined) value.sources.ccswitch = true;
754
+ if (value.disabledSkills && typeof value.disabledSkills === "object" && !Array.isArray(value.disabledSkills) && value.disabledSkills.ccswitch === undefined) value.disabledSkills.ccswitch = [];
755
+ if (value.enabledSkills && typeof value.enabledSkills === "object" && !Array.isArray(value.enabledSkills) && value.enabledSkills.ccswitch === undefined) value.enabledSkills.ccswitch = [];
756
+ return value;
757
+ }
758
+
670
759
  function normalizeManagerState(value) {
671
760
  const normalized = defaultManagerState();
672
761
  if (!value || typeof value !== "object" || Array.isArray(value)) return normalized;
@@ -692,7 +781,7 @@ function normalizeManagerState(value) {
692
781
  export async function readManagerState() {
693
782
  try {
694
783
  const raw = await fs.readFile(managerStatePath(), "utf8");
695
- const parsed = JSON.parse(raw);
784
+ const parsed = migrateManagerStateDocument(JSON.parse(raw));
696
785
  if (!validManagerStateDocument(parsed)) throw codedError("invalid manager state schema", "error.state.invalid");
697
786
  return { state: normalizeManagerState(parsed), warning: null, writable: true };
698
787
  } catch (error) {
@@ -1449,7 +1538,7 @@ export async function skillDetail(key, name, options = {}) {
1449
1538
  if (!root) return { ok: false, error: `未知技能来源: ${key}`, code: "error.root.unknown", params: { root: key } };
1450
1539
  const entry = await resolveEntry(root.path, name);
1451
1540
  if (!entry) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
1452
- const raw = await fs.readFile(entry.docPath, "utf8");
1541
+ const raw = await fs.readFile(entry.realDocPath || entry.docPath, "utf8");
1453
1542
  const doc = parseSkillDoc(raw);
1454
1543
  const summary = entryOf(name, entry.kind, entry.docPath, doc);
1455
1544
  return {
@@ -1471,8 +1560,10 @@ export async function skillDetail(key, name, options = {}) {
1471
1560
  export async function listProviderCandidates(options = {}) {
1472
1561
  const policyResult = await readManagerState();
1473
1562
  const candidates = [];
1474
- for (const root of userRoots()) {
1475
- const scanned = await scanEntries(root.path);
1563
+ const user = userRoots();
1564
+ const userScans = await scanDeduplicatedUserRoots(user);
1565
+ for (const root of user) {
1566
+ const scanned = userScans.get(root.key);
1476
1567
  for (const entry of scanned.entries) {
1477
1568
  if (!entry.loadable) continue;
1478
1569
  const policy = effectiveSkillPolicy(policyResult, root, entry);
@@ -1485,8 +1576,8 @@ export async function listProviderCandidates(options = {}) {
1485
1576
  provider: "dsh-skills-manager-external",
1486
1577
  source: root.key === "dsh" ? "user-dsh" : `agent-${root.key}`,
1487
1578
  rank: root.key === "dsh" ? USER_DSH_POLICY_RANK : root.rank,
1488
- locator: { rootKey: root.key, entryName: entry.name, path: entry.docPath },
1489
- resourceBase: { kind: "directory", path: entry.kind === "bundle" ? dirname(entry.docPath) : root.path },
1579
+ locator: { rootKey: root.key, entryName: entry.name, path: entry.docPath, realEntryPath: entry.realEntryPath, realDocPath: entry.realDocPath },
1580
+ resourceBase: { kind: "directory", path: entry.kind === "bundle" ? entry.realEntryPath : root.path },
1490
1581
  path: entry.docPath,
1491
1582
  metadata: { dshSkillsManager: { root: root.key, readOnly: !root.mutable, sourceReadOnly: !root.mutable, policyOnly: root.key === "dsh" } },
1492
1583
  });
@@ -1531,7 +1622,9 @@ export async function getProviderSkill(candidate, options = {}) {
1531
1622
  if (!root) return undefined;
1532
1623
  const entry = await resolveEntry(root.path, String(locator.entryName || ""));
1533
1624
  if (!entry || resolve(entry.docPath) !== resolve(locator.path)) return undefined;
1534
- const doc = parseSkillDoc(await fs.readFile(entry.docPath, "utf8"));
1625
+ if (locator.realEntryPath && pathIdentity(entry.realEntryPath) !== pathIdentity(locator.realEntryPath)) return undefined;
1626
+ if (locator.realDocPath && pathIdentity(entry.realDocPath) !== pathIdentity(locator.realDocPath)) return undefined;
1627
+ const doc = parseSkillDoc(await fs.readFile(entry.realDocPath || entry.docPath, "utf8"));
1535
1628
  const summary = entryOf(locator.entryName, entry.kind, entry.docPath, doc);
1536
1629
  if (!summary.loadable || summary.declaredName !== candidate.name) return undefined;
1537
1630
  return {
@@ -1575,6 +1668,7 @@ function markWinners(items, options = {}) {
1575
1668
  /** DSH、常见 Agent 与活动 Session 项目根的技能快照。 */
1576
1669
  export async function state(options = {}) {
1577
1670
  const user = userRoots();
1671
+ const userScans = await scanDeduplicatedUserRoots(user);
1578
1672
  const projectWarnings = [];
1579
1673
  const scoped = await projectRoots(options.projectCwds, projectWarnings);
1580
1674
  const policyResult = await readManagerState();
@@ -1582,7 +1676,7 @@ export async function state(options = {}) {
1582
1676
  const result = { roots: [], projects: [], trash, warnings: [...(policyResult.warning ? [policyResult.warning] : []), ...projectWarnings] };
1583
1677
  const all = [];
1584
1678
  for (const root of [...scoped, ...user]) {
1585
- const { exists, entries } = await scanEntries(root.path);
1679
+ const { exists, entries } = root.scope === "project" ? await scanEntries(root.path) : userScans.get(root.key);
1586
1680
  // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
1587
1681
  if (root.scope === "project" && !exists && root.kind !== "project-dsh") continue;
1588
1682
  const skills = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "NPM-installable DSH Web plugin for safely loading and managing skills across DSH and common local Agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {