@michengai/dsh-skills-manager 0.1.32 → 0.1.34
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 +18 -0
- package/CHANGELOG.zh-CN.md +18 -0
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/lib/client.js +2 -2
- package/lib/core.js +219 -58
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ The five most recent published versions are listed below.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.1.34 — 2026-09-02
|
|
10
|
+
|
|
11
|
+
- Made direct Skill entries the canonical owner when the same real bundle is also exposed through Agent distribution links. The canonical provider candidate inherits the strongest distribution rank, so disabling CC Switch cannot fall back to a linked public-Agent copy.
|
|
12
|
+
- Required discovered real bundle and document paths for both user and project Provider candidates, revalidated both paths at load time, and aligned policy toggles with real-document reads.
|
|
13
|
+
- Scanned user roots concurrently and reused one resolved set of trusted read-only roots per scan without caching external filesystem state across requests.
|
|
14
|
+
- Preserved a per-Skill disable while its current distribution entry changes ownership to a canonical source. A canonical source's explicit policy still takes precedence over inherited distribution-link policies.
|
|
15
|
+
- Made detail visibility reproduce the same user-root deduplication as state snapshots from the requested entry's physical owner, avoiding unrelated Skill document reads; entry resolution, detail reads, and Provider reads fail closed when files disappear during a filesystem race.
|
|
16
|
+
|
|
17
|
+
Published package: [`@michengai/dsh-skills-manager@0.1.34`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.34).
|
|
18
|
+
|
|
19
|
+
## 0.1.33 — 2026-09-02
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- Kept writable DSH roots, project sources, linked roots, hidden or invalid targets, arbitrary external targets, and import symlinks rejected.
|
|
24
|
+
|
|
25
|
+
Published package: [`@michengai/dsh-skills-manager@0.1.33`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.33).
|
|
26
|
+
|
|
9
27
|
## 0.1.32 — 2026-08-31
|
|
10
28
|
|
|
11
29
|
- 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.
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
## 0.1.34 — 2026-09-02
|
|
10
|
+
|
|
11
|
+
- 同一真实 Skill 同时存在直接条目和 Agent 分发链接时,改为由直接条目归属;规范候选继承所有分发入口中的最强 rank,停用 CC Switch 后不会回流到公共 Agent 链接副本。
|
|
12
|
+
- 用户级与项目级 Provider 候选必须携带扫描时发现的真实 bundle 和文档路径,加载时同时复验;启停读取也统一使用真实文档路径。
|
|
13
|
+
- 用户根改为并发扫描,并在单次扫描内复用已解析的受信只读根;不跨请求缓存外部文件系统状态。
|
|
14
|
+
- 当前分发条目归属迁移到规范来源时,保留原来源的逐 Skill 停用;规范来源自身的显式策略优先于继承的分发链接策略。
|
|
15
|
+
- 详情接口按被请求条目的物理所有者复现状态快照的用户根去重结果,避免读取无关 Skill 文档;文件在扫描后消失时,条目解析、详情读取和 Provider 读取均失败关闭。
|
|
16
|
+
|
|
17
|
+
发布包:[`@michengai/dsh-skills-manager@0.1.34`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.34)。
|
|
18
|
+
|
|
19
|
+
## 0.1.33 — 2026-09-02
|
|
20
|
+
|
|
21
|
+
- 新增默认开启的只读 `CC Switch` 来源,固定读取 `~\.cc-switch\skills`,支持来源级与逐 Skill 本地启停;旧状态文件自动补齐新来源,不会因缺少新键失败关闭。
|
|
22
|
+
- 用户级只读来源支持指向已知只读 Skills 根直接子目录的顶层技能链接;Provider 在加载时复验真实目标,并按真实路径去重 CC Switch 分发到 Codex、Claude 等目录的副本。
|
|
23
|
+
- 可写 DSH、项目来源、技能根链接、隐藏或非法目标以及任意根外目标继续拒绝,导入符号链接边界不变。
|
|
24
|
+
|
|
25
|
+
发布包:[`@michengai/dsh-skills-manager@0.1.33`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.33)。
|
|
26
|
+
|
|
9
27
|
## 0.1.32 — 2026-08-31
|
|
10
28
|
|
|
11
29
|
- 将用户 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 },
|
|
@@ -76,7 +77,7 @@ export function userRoots() {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
function projectIdentity(path) {
|
|
79
|
-
const canonical =
|
|
80
|
+
const canonical = pathIdentity(path);
|
|
80
81
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -126,7 +127,7 @@ export async function projectRoots(projectCwds = [], diagnostics) {
|
|
|
126
127
|
}
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
129
|
-
const identity =
|
|
130
|
+
const identity = pathIdentity(found.root);
|
|
130
131
|
const existing = projects.get(identity) || { root: found.root, cwds: new Set() };
|
|
131
132
|
existing.cwds.add(found.cwd);
|
|
132
133
|
projects.set(identity, existing);
|
|
@@ -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,58 @@ async function lstatOrNull(path) {
|
|
|
344
350
|
}
|
|
345
351
|
}
|
|
346
352
|
|
|
353
|
+
/** 预解析可作为用户级链接目标的只读根,供一次扫描中的所有链接复用。 */
|
|
354
|
+
async function resolvedReadonlyUserRoots() {
|
|
355
|
+
const roots = userRoots().filter((root) => !root.mutable && root.scope !== "project");
|
|
356
|
+
const resolvedRoots = await Promise.all(roots.map(async (root) => {
|
|
357
|
+
const stat = await lstatOrNull(root.path);
|
|
358
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
359
|
+
try {
|
|
360
|
+
return { root, realPath: await fs.realpath(root.path) };
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}));
|
|
365
|
+
return resolvedRoots.filter(Boolean);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 只接受用户级只读来源中的顶层目录链接,且目标必须是另一个已知只读 Skills 根的直接子目录。
|
|
370
|
+
* 这样可以兼容 CC Switch 的分发链接,同时不把任意文件系统路径扩展成技能来源。
|
|
371
|
+
*/
|
|
372
|
+
async function resolveTrustedLinkedBundle(root, bundlePath, trustedReadonlyRoots = resolvedReadonlyUserRoots()) {
|
|
373
|
+
const sourceRoot = rootDefinition(root);
|
|
374
|
+
if (!sourceRoot || sourceRoot.mutable || sourceRoot.scope === "project") return null;
|
|
375
|
+
|
|
376
|
+
let realEntryPath;
|
|
377
|
+
try {
|
|
378
|
+
realEntryPath = await fs.realpath(bundlePath);
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const realEntryStat = await lstatOrNull(realEntryPath);
|
|
383
|
+
if (!realEntryStat || !realEntryStat.isDirectory() || realEntryStat.isSymbolicLink()) return null;
|
|
384
|
+
|
|
385
|
+
const resolvedRoots = await trustedReadonlyRoots;
|
|
386
|
+
const resolvedSourceRoot = resolvedRoots.find((candidate) => pathIdentity(candidate.root.path) === pathIdentity(sourceRoot.path));
|
|
387
|
+
if (!resolvedSourceRoot) return null;
|
|
388
|
+
let trusted = false;
|
|
389
|
+
for (const targetRoot of resolvedRoots) {
|
|
390
|
+
if (pathIdentity(targetRoot.realPath) === pathIdentity(resolvedSourceRoot.realPath)) continue;
|
|
391
|
+
const acceptedTarget = entryPath(targetRoot.realPath, basename(realEntryPath));
|
|
392
|
+
if (acceptedTarget && pathIdentity(acceptedTarget) === pathIdentity(realEntryPath)) {
|
|
393
|
+
trusted = true;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!trusted) return null;
|
|
398
|
+
|
|
399
|
+
const realDocPath = join(realEntryPath, "SKILL.md");
|
|
400
|
+
const docStat = await lstatOrNull(realDocPath);
|
|
401
|
+
if (!docStat || !docStat.isFile() || docStat.isSymbolicLink()) return null;
|
|
402
|
+
return { realEntryPath, realDocPath };
|
|
403
|
+
}
|
|
404
|
+
|
|
347
405
|
/** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
|
|
348
406
|
export function entryPath(root, name) {
|
|
349
407
|
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;
|
|
@@ -524,27 +582,37 @@ export function parseBoolValue(raw) {
|
|
|
524
582
|
|
|
525
583
|
/** 按名称解析条目(bundle 优先,其次 flat)。找不到返回 null。 */
|
|
526
584
|
export async function resolveEntry(root, name) {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
585
|
+
try {
|
|
586
|
+
const bundlePath = entryPath(root, name);
|
|
587
|
+
if (bundlePath === null) return null;
|
|
588
|
+
const rootPath = resolve(root);
|
|
589
|
+
const rootStat = await lstatOrNull(rootPath);
|
|
590
|
+
if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
|
|
591
|
+
const rootReal = await resolvedPath(rootPath);
|
|
592
|
+
const bundleStat = await lstatOrNull(bundlePath);
|
|
593
|
+
if (bundleStat && bundleStat.isDirectory() && !bundleStat.isSymbolicLink()) {
|
|
594
|
+
const bundleDoc = join(bundlePath, "SKILL.md");
|
|
595
|
+
const docStat = await lstatOrNull(bundleDoc);
|
|
596
|
+
if (docStat && docStat.isFile() && !docStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, bundleDoc)) {
|
|
597
|
+
return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath, realDocPath: await fs.realpath(bundleDoc), realEntryPath: await fs.realpath(bundlePath), linked: false };
|
|
598
|
+
}
|
|
539
599
|
}
|
|
600
|
+
if (bundleStat && bundleStat.isSymbolicLink()) {
|
|
601
|
+
const linked = await resolveTrustedLinkedBundle(rootPath, bundlePath);
|
|
602
|
+
if (linked) return { kind: "bundle", docPath: join(bundlePath, "SKILL.md"), entryPath: bundlePath, ...linked, linked: true };
|
|
603
|
+
}
|
|
604
|
+
const flatDoc = resolve(rootPath, `${name}.md`);
|
|
605
|
+
if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
|
|
606
|
+
const flatStat = await lstatOrNull(flatDoc);
|
|
607
|
+
if (flatStat && flatStat.isFile() && !flatStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, flatDoc)) {
|
|
608
|
+
const realDocPath = await fs.realpath(flatDoc);
|
|
609
|
+
return { kind: "flat", docPath: flatDoc, entryPath: flatDoc, realDocPath, realEntryPath: realDocPath, linked: false };
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
} catch {
|
|
613
|
+
// lstat 与 realpath/readFile 之间允许来源变化,调用方统一按不存在处理。
|
|
614
|
+
return null;
|
|
540
615
|
}
|
|
541
|
-
const flatDoc = resolve(rootPath, `${name}.md`);
|
|
542
|
-
if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
|
|
543
|
-
const flatStat = await lstatOrNull(flatDoc);
|
|
544
|
-
if (flatStat && flatStat.isFile() && !flatStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, flatDoc)) {
|
|
545
|
-
return { kind: "flat", docPath: flatDoc, entryPath: flatDoc };
|
|
546
|
-
}
|
|
547
|
-
return null;
|
|
548
616
|
}
|
|
549
617
|
|
|
550
618
|
function entryOf(name, kind, docPath, doc) {
|
|
@@ -578,7 +646,7 @@ function entryOf(name, kind, docPath, doc) {
|
|
|
578
646
|
}
|
|
579
647
|
|
|
580
648
|
/** 扫描一个技能根(只扫一层)。返回 { exists, entries }。 */
|
|
581
|
-
export async function scanEntries(root) {
|
|
649
|
+
export async function scanEntries(root, options = {}) {
|
|
582
650
|
const rootStat = await lstatOrNull(resolve(root));
|
|
583
651
|
if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink()) return { exists: false, entries: [] };
|
|
584
652
|
let items;
|
|
@@ -589,22 +657,33 @@ export async function scanEntries(root) {
|
|
|
589
657
|
}
|
|
590
658
|
const byName = new Map();
|
|
591
659
|
const rootReal = await resolvedPath(root);
|
|
660
|
+
let trustedReadonlyRoots = options.trustedReadonlyRoots;
|
|
592
661
|
for (const it of items) {
|
|
593
662
|
try {
|
|
594
|
-
if (it.isSymbolicLink())
|
|
663
|
+
if (it.isSymbolicLink()) {
|
|
664
|
+
const bundlePath = entryPath(root, it.name);
|
|
665
|
+
if (bundlePath === null) continue;
|
|
666
|
+
trustedReadonlyRoots ||= resolvedReadonlyUserRoots();
|
|
667
|
+
const linked = await resolveTrustedLinkedBundle(root, bundlePath, trustedReadonlyRoots);
|
|
668
|
+
if (!linked) continue;
|
|
669
|
+
const doc = parseSkillDoc(await fs.readFile(linked.realDocPath, "utf8"));
|
|
670
|
+
byName.set(it.name, { ...entryOf(it.name, "bundle", join(bundlePath, "SKILL.md"), doc), entryPath: bundlePath, ...linked, linked: true });
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
595
673
|
if (it.isDirectory() && entryPath(root, it.name) !== null) {
|
|
596
674
|
const docPath = join(root, it.name, "SKILL.md");
|
|
597
675
|
const st = await lstatOrNull(docPath);
|
|
598
676
|
if (!st || !st.isFile() || st.isSymbolicLink() || !(await isInsideResolvedRoot(rootReal, docPath))) continue;
|
|
599
677
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
600
|
-
byName.set(it.name, entryOf(it.name, "bundle", docPath, doc));
|
|
678
|
+
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
679
|
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md" && entryPath(root, it.name.slice(0, -3)) !== null) {
|
|
602
680
|
const skillName = it.name.slice(0, -3);
|
|
603
681
|
if (byName.has(skillName)) continue;
|
|
604
682
|
const docPath = join(root, it.name);
|
|
605
683
|
if (!(await isInsideResolvedRoot(rootReal, docPath))) continue;
|
|
606
684
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
607
|
-
|
|
685
|
+
const realDocPath = await fs.realpath(docPath);
|
|
686
|
+
byName.set(skillName, { ...entryOf(skillName, "flat", docPath, doc), entryPath: docPath, realDocPath, realEntryPath: realDocPath, linked: false });
|
|
608
687
|
}
|
|
609
688
|
} catch {
|
|
610
689
|
/* 跳过不可读条目 */
|
|
@@ -614,6 +693,53 @@ export async function scanEntries(root) {
|
|
|
614
693
|
return { exists: true, entries };
|
|
615
694
|
}
|
|
616
695
|
|
|
696
|
+
/** 同一真实技能优先归属直接 SSOT;同类条目再按来源 rank 决胜。 */
|
|
697
|
+
async function scanDeduplicatedUserRoots(roots = userRoots()) {
|
|
698
|
+
const trustedReadonlyRoots = resolvedReadonlyUserRoots();
|
|
699
|
+
const results = await Promise.all(roots.map(async (root) => [root, await scanEntries(root.path, { trustedReadonlyRoots })]));
|
|
700
|
+
const scans = new Map();
|
|
701
|
+
const groups = new Map();
|
|
702
|
+
for (const [root, scan] of results) {
|
|
703
|
+
scans.set(root.key, scan);
|
|
704
|
+
for (const entry of scan.entries) {
|
|
705
|
+
const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
|
|
706
|
+
const group = groups.get(identity) || [];
|
|
707
|
+
group.push({ root, entry });
|
|
708
|
+
groups.set(identity, group);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const winners = new Set();
|
|
712
|
+
for (const group of groups.values()) {
|
|
713
|
+
group.sort((left, right) => Number(left.entry.linked) - Number(right.entry.linked) || left.root.rank - right.root.rank);
|
|
714
|
+
const winner = group[0];
|
|
715
|
+
winner.entry.providerRank = Math.min(...group.map((item) => item.root.rank));
|
|
716
|
+
winner.entry.policyRootKeys = group.map((item) => item.root.key);
|
|
717
|
+
winners.add(winner.entry);
|
|
718
|
+
}
|
|
719
|
+
for (const scan of scans.values()) {
|
|
720
|
+
scan.entries = scan.entries.filter((entry) => winners.has(entry));
|
|
721
|
+
}
|
|
722
|
+
return scans;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* 详情只解析被请求的条目,再从物理路径确定其直接所有者。
|
|
727
|
+
* 受信链接只能指向已知只读根的顶层目录,故无需扫描并读取全部技能文档即可复现去重归属。
|
|
728
|
+
*/
|
|
729
|
+
async function visibleEntryForRoot(root, name) {
|
|
730
|
+
if (root.scope === "project") return resolveEntry(root.path, name);
|
|
731
|
+
const entry = await resolveEntry(root.path, name);
|
|
732
|
+
if (!entry) return null;
|
|
733
|
+
const identity = pathIdentity(entry.realEntryPath || entry.entryPath || entry.docPath);
|
|
734
|
+
const owners = (await resolvedReadonlyUserRoots())
|
|
735
|
+
.filter((candidate) => {
|
|
736
|
+
const expected = entryPath(candidate.realPath, basename(entry.realEntryPath || entry.entryPath || entry.docPath));
|
|
737
|
+
return expected !== null && pathIdentity(expected) === identity;
|
|
738
|
+
})
|
|
739
|
+
.sort((left, right) => left.root.rank - right.root.rank);
|
|
740
|
+
return owners.length === 0 || owners[0].root.key === root.key ? entry : null;
|
|
741
|
+
}
|
|
742
|
+
|
|
617
743
|
// ── Manager 本地策略(外部源只读,启停状态写入 DSH_HOME)────────────────────
|
|
618
744
|
|
|
619
745
|
function defaultManagerState() {
|
|
@@ -667,6 +793,15 @@ function validManagerStateDocument(value) {
|
|
|
667
793
|
return true;
|
|
668
794
|
}
|
|
669
795
|
|
|
796
|
+
/** 仅对可识别的 version 1 状态补齐 CC Switch 键,其余结构仍交由严格校验失败关闭。 */
|
|
797
|
+
function migrateManagerStateDocument(value) {
|
|
798
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1) return value;
|
|
799
|
+
if (value.sources && typeof value.sources === "object" && !Array.isArray(value.sources) && value.sources.ccswitch === undefined) value.sources.ccswitch = true;
|
|
800
|
+
if (value.disabledSkills && typeof value.disabledSkills === "object" && !Array.isArray(value.disabledSkills) && value.disabledSkills.ccswitch === undefined) value.disabledSkills.ccswitch = [];
|
|
801
|
+
if (value.enabledSkills && typeof value.enabledSkills === "object" && !Array.isArray(value.enabledSkills) && value.enabledSkills.ccswitch === undefined) value.enabledSkills.ccswitch = [];
|
|
802
|
+
return value;
|
|
803
|
+
}
|
|
804
|
+
|
|
670
805
|
function normalizeManagerState(value) {
|
|
671
806
|
const normalized = defaultManagerState();
|
|
672
807
|
if (!value || typeof value !== "object" || Array.isArray(value)) return normalized;
|
|
@@ -692,7 +827,7 @@ function normalizeManagerState(value) {
|
|
|
692
827
|
export async function readManagerState() {
|
|
693
828
|
try {
|
|
694
829
|
const raw = await fs.readFile(managerStatePath(), "utf8");
|
|
695
|
-
const parsed = JSON.parse(raw);
|
|
830
|
+
const parsed = migrateManagerStateDocument(JSON.parse(raw));
|
|
696
831
|
if (!validManagerStateDocument(parsed)) throw codedError("invalid manager state schema", "error.state.invalid");
|
|
697
832
|
return { state: normalizeManagerState(parsed), warning: null, writable: true };
|
|
698
833
|
} catch (error) {
|
|
@@ -710,14 +845,21 @@ async function writeManagerState(value) {
|
|
|
710
845
|
await writeFileAtomically(managerStatePath(), `${JSON.stringify(normalizeManagerState(value), null, 2)}\n`);
|
|
711
846
|
}
|
|
712
847
|
|
|
713
|
-
function managerSkillOverride(policy, rootKey, name) {
|
|
848
|
+
function managerSkillOverride(policy, rootKey, name, policyRootKeys = []) {
|
|
714
849
|
if ((policy.enabledSkills[rootKey] || []).includes(name)) return true;
|
|
715
850
|
if ((policy.disabledSkills[rootKey] || []).includes(name)) return false;
|
|
851
|
+
let inheritedEnable = false;
|
|
852
|
+
for (const key of policyRootKeys) {
|
|
853
|
+
if (key === rootKey) continue;
|
|
854
|
+
if ((policy.disabledSkills[key] || []).includes(name)) return false;
|
|
855
|
+
if ((policy.enabledSkills[key] || []).includes(name)) inheritedEnable = true;
|
|
856
|
+
}
|
|
857
|
+
if (inheritedEnable) return true;
|
|
716
858
|
return undefined;
|
|
717
859
|
}
|
|
718
860
|
|
|
719
861
|
function effectiveSkillPolicy(policyResult, root, entry) {
|
|
720
|
-
const override = managerSkillOverride(policyResult.state, root.key, entry.name);
|
|
862
|
+
const override = managerSkillOverride(policyResult.state, root.key, entry.name, entry.policyRootKeys);
|
|
721
863
|
const sourceEnabled = root.key === "dsh" || root.scope === "project" || policyResult.state.sources[root.key] !== false;
|
|
722
864
|
if (policyResult.writable === false || !sourceEnabled || override === false) {
|
|
723
865
|
return { override, sourceEnabled, modelInvocable: false, userInvocable: false, enabled: false };
|
|
@@ -776,7 +918,12 @@ async function setPolicySkillEnabled(root, name, enabled, log) {
|
|
|
776
918
|
if (!definition) return readonlyError("toggle");
|
|
777
919
|
const resolved = await resolveEntry(definition.path, name);
|
|
778
920
|
if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
779
|
-
|
|
921
|
+
let summary;
|
|
922
|
+
try {
|
|
923
|
+
summary = entryOf(name, resolved.kind, resolved.docPath, parseSkillDoc(await fs.readFile(resolved.realDocPath || resolved.docPath, "utf8")));
|
|
924
|
+
} catch {
|
|
925
|
+
return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
926
|
+
}
|
|
780
927
|
if (!summary.hasFrontmatter) {
|
|
781
928
|
return { ok: false, error: `技能缺少完整 frontmatter,无法${enabled ? "启用" : "停用"}: ${name}`, code: "error.skill.noFrontmatter", params: { name, action: enabled ? "enable" : "disable" } };
|
|
782
929
|
}
|
|
@@ -893,8 +1040,8 @@ async function restoreRootDefinition(metadata, options = {}) {
|
|
|
893
1040
|
|| !isAbsolute(metadata.root.projectRoot)
|
|
894
1041
|
) return { ok: false, error: `回收站条目来源非法: ${metadata.id}`, code: "error.trash.invalid", params: { id: metadata.id } };
|
|
895
1042
|
const roots = await projectRoots(options.projectCwds);
|
|
896
|
-
const normalizedProjectRoot =
|
|
897
|
-
const root = roots.find((item) => item.key === metadata.root.key && item.kind === "project-dsh" && (
|
|
1043
|
+
const normalizedProjectRoot = pathIdentity(metadata.root.projectRoot);
|
|
1044
|
+
const root = roots.find((item) => item.key === metadata.root.key && item.kind === "project-dsh" && pathIdentity(item.projectRoot) === normalizedProjectRoot);
|
|
898
1045
|
if (!root) {
|
|
899
1046
|
return {
|
|
900
1047
|
ok: false,
|
|
@@ -1447,9 +1594,14 @@ export async function skillDetail(key, name, options = {}) {
|
|
|
1447
1594
|
const scopedRoots = await projectRoots(options.projectCwds);
|
|
1448
1595
|
const root = rootByKey(key) || scopedRoots.find((item) => item.key === key);
|
|
1449
1596
|
if (!root) return { ok: false, error: `未知技能来源: ${key}`, code: "error.root.unknown", params: { root: key } };
|
|
1450
|
-
const entry = await
|
|
1597
|
+
const entry = await visibleEntryForRoot(root, name);
|
|
1451
1598
|
if (!entry) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
1452
|
-
|
|
1599
|
+
let raw;
|
|
1600
|
+
try {
|
|
1601
|
+
raw = await fs.readFile(entry.realDocPath || entry.docPath, "utf8");
|
|
1602
|
+
} catch {
|
|
1603
|
+
return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
|
|
1604
|
+
}
|
|
1453
1605
|
const doc = parseSkillDoc(raw);
|
|
1454
1606
|
const summary = entryOf(name, entry.kind, entry.docPath, doc);
|
|
1455
1607
|
return {
|
|
@@ -1471,8 +1623,10 @@ export async function skillDetail(key, name, options = {}) {
|
|
|
1471
1623
|
export async function listProviderCandidates(options = {}) {
|
|
1472
1624
|
const policyResult = await readManagerState();
|
|
1473
1625
|
const candidates = [];
|
|
1474
|
-
|
|
1475
|
-
|
|
1626
|
+
const user = userRoots();
|
|
1627
|
+
const userScans = await scanDeduplicatedUserRoots(user);
|
|
1628
|
+
for (const root of user) {
|
|
1629
|
+
const scanned = userScans.get(root.key);
|
|
1476
1630
|
for (const entry of scanned.entries) {
|
|
1477
1631
|
if (!entry.loadable) continue;
|
|
1478
1632
|
const policy = effectiveSkillPolicy(policyResult, root, entry);
|
|
@@ -1484,9 +1638,9 @@ export async function listProviderCandidates(options = {}) {
|
|
|
1484
1638
|
invocation: { modelInvocable: policy.modelInvocable, userInvocable: policy.userInvocable },
|
|
1485
1639
|
provider: "dsh-skills-manager-external",
|
|
1486
1640
|
source: root.key === "dsh" ? "user-dsh" : `agent-${root.key}`,
|
|
1487
|
-
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" ?
|
|
1641
|
+
rank: root.key === "dsh" ? USER_DSH_POLICY_RANK : entry.providerRank ?? root.rank,
|
|
1642
|
+
locator: { rootKey: root.key, entryName: entry.name, path: entry.docPath, realEntryPath: entry.realEntryPath, realDocPath: entry.realDocPath },
|
|
1643
|
+
resourceBase: { kind: "directory", path: entry.kind === "bundle" ? entry.realEntryPath : root.path },
|
|
1490
1644
|
path: entry.docPath,
|
|
1491
1645
|
metadata: { dshSkillsManager: { root: root.key, readOnly: !root.mutable, sourceReadOnly: !root.mutable, policyOnly: root.key === "dsh" } },
|
|
1492
1646
|
});
|
|
@@ -1510,8 +1664,8 @@ export async function listProviderCandidates(options = {}) {
|
|
|
1510
1664
|
provider: "dsh-skills-manager-external",
|
|
1511
1665
|
source: root.kind,
|
|
1512
1666
|
rank: root.rank - 1,
|
|
1513
|
-
locator: { rootKey: root.key, entryName: entry.name, path: entry.docPath },
|
|
1514
|
-
resourceBase: { kind: "directory", path: entry.kind === "bundle" ?
|
|
1667
|
+
locator: { rootKey: root.key, entryName: entry.name, path: entry.docPath, realEntryPath: entry.realEntryPath, realDocPath: entry.realDocPath },
|
|
1668
|
+
resourceBase: { kind: "directory", path: entry.kind === "bundle" ? entry.realEntryPath : root.path },
|
|
1515
1669
|
path: entry.docPath,
|
|
1516
1670
|
metadata: { dshSkillsManager: { root: root.key, readOnly: !root.mutable, sourceReadOnly: !root.mutable, policyOnly: true } },
|
|
1517
1671
|
});
|
|
@@ -1523,28 +1677,34 @@ export async function listProviderCandidates(options = {}) {
|
|
|
1523
1677
|
|
|
1524
1678
|
export async function getProviderSkill(candidate, options = {}) {
|
|
1525
1679
|
const locator = candidate && candidate.locator;
|
|
1526
|
-
if (!locator || typeof locator.path !== "string" || typeof locator.rootKey !== "string") return undefined;
|
|
1680
|
+
if (!locator || typeof locator.path !== "string" || typeof locator.rootKey !== "string" || typeof locator.realEntryPath !== "string" || typeof locator.realDocPath !== "string") return undefined;
|
|
1527
1681
|
let root = rootByKey(locator.rootKey);
|
|
1528
1682
|
if (!root && PROJECT_ROOT_KEY_RE.test(locator.rootKey) && typeof options.cwd === "string") {
|
|
1529
1683
|
root = (await projectRoots([options.cwd])).find((item) => item.key === locator.rootKey);
|
|
1530
1684
|
}
|
|
1531
1685
|
if (!root) return undefined;
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1686
|
+
try {
|
|
1687
|
+
const entry = await resolveEntry(root.path, String(locator.entryName || ""));
|
|
1688
|
+
if (!entry || resolve(entry.docPath) !== resolve(locator.path)) return undefined;
|
|
1689
|
+
if (pathIdentity(entry.realEntryPath) !== pathIdentity(locator.realEntryPath)) return undefined;
|
|
1690
|
+
if (pathIdentity(entry.realDocPath) !== pathIdentity(locator.realDocPath)) return undefined;
|
|
1691
|
+
const doc = parseSkillDoc(await fs.readFile(entry.realDocPath || entry.docPath, "utf8"));
|
|
1692
|
+
const summary = entryOf(locator.entryName, entry.kind, entry.docPath, doc);
|
|
1693
|
+
if (!summary.loadable || summary.declaredName !== candidate.name) return undefined;
|
|
1694
|
+
return {
|
|
1695
|
+
name: candidate.name,
|
|
1696
|
+
description: candidate.description,
|
|
1697
|
+
invocation: candidate.invocation,
|
|
1698
|
+
provider: candidate.provider,
|
|
1699
|
+
source: candidate.source,
|
|
1700
|
+
resourceBase: candidate.resourceBase,
|
|
1701
|
+
path: candidate.path,
|
|
1702
|
+
metadata: candidate.metadata,
|
|
1703
|
+
content: doc.body.trim(),
|
|
1704
|
+
};
|
|
1705
|
+
} catch {
|
|
1706
|
+
return undefined;
|
|
1707
|
+
}
|
|
1548
1708
|
}
|
|
1549
1709
|
|
|
1550
1710
|
// ── 状态快照 ────────────────────────────────────────────────────────────────
|
|
@@ -1575,6 +1735,7 @@ function markWinners(items, options = {}) {
|
|
|
1575
1735
|
/** DSH、常见 Agent 与活动 Session 项目根的技能快照。 */
|
|
1576
1736
|
export async function state(options = {}) {
|
|
1577
1737
|
const user = userRoots();
|
|
1738
|
+
const userScans = await scanDeduplicatedUserRoots(user);
|
|
1578
1739
|
const projectWarnings = [];
|
|
1579
1740
|
const scoped = await projectRoots(options.projectCwds, projectWarnings);
|
|
1580
1741
|
const policyResult = await readManagerState();
|
|
@@ -1582,7 +1743,7 @@ export async function state(options = {}) {
|
|
|
1582
1743
|
const result = { roots: [], projects: [], trash, warnings: [...(policyResult.warning ? [policyResult.warning] : []), ...projectWarnings] };
|
|
1583
1744
|
const all = [];
|
|
1584
1745
|
for (const root of [...scoped, ...user]) {
|
|
1585
|
-
const { exists, entries } = await scanEntries(root.path);
|
|
1746
|
+
const { exists, entries } = root.scope === "project" ? await scanEntries(root.path) : userScans.get(root.key);
|
|
1586
1747
|
// 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
|
|
1587
1748
|
if (root.scope === "project" && !exists && root.kind !== "project-dsh") continue;
|
|
1588
1749
|
const skills = [];
|
package/package.json
CHANGED