@michengai/dsh-skills-manager 0.1.30 → 0.1.31

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
@@ -4,6 +4,24 @@
4
4
 
5
5
  The five most recent published versions are listed below.
6
6
 
7
+ ## Unreleased
8
+
9
+ ## 0.1.31 — 2026-08-30
10
+
11
+ - Added project Skill discovery for active Session workspaces, covering project-root `.dsh/skills` and `.agents/skills` with stable workspace-specific identities and official rank order.
12
+ - Kept project files under DSH's scoped filesystem provider instead of registering a duplicate provider or reusing user-global toggle state.
13
+ - Labeled project entries as discovered rather than loaded, exposed workspace-specific rank/path evidence and a manual Refresh action, and warned when an active workspace is not readable from the host.
14
+ - Added regression coverage for nearest-git-root resolution, duplicate Session workspaces, same-name priority within one project, same-name isolation across projects, live modification/removal, stale source identities, and linked bundles outside the project skill root.
15
+ - Added Settings creation and recoverable Trash deletion for active project `.dsh/skills`, reusing the global whole-bundle rollback/publish flow while keeping project `.agents/skills` read-only.
16
+ - Trash now records the original user or project source, displays it in Settings, and restores project entries only after resolving the same opaque root from a currently active Session workspace.
17
+ - Added enable/disable controls for project `.dsh/skills`; they update the Skill's invocation-policy fields while leaving Session-scoped catalog ownership with DSH's official provider.
18
+ - Fixed project Trash and restore failing with `EXDEV` when the project and `$DSH_HOME` are on different volumes by using a guarded copy-then-hide fallback.
19
+ - Hid empty project roots from the main source list while preserving them as Create Skill destinations, and removed the unsupported project source-wide toggle without affecting per-Skill enable/disable.
20
+ - Fixed writable project discovery falling back to a non-Git Session cwd and potentially aliasing user-level Skill roots; project sources now require a real `.git` ancestor and are hidden and rejected when they overlap any user Skill root.
21
+ - Stopped list and detail scans from following linked Skill roots, including project `.dsh`/`.agents` containers and Windows reparse points, while retaining the existing linked-bundle containment guard.
22
+
23
+ Published package: [`@michengai/dsh-skills-manager@0.1.31`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.31).
24
+
7
25
  ## 0.1.30 — 2026-08-28
8
26
 
9
27
  - Fixed reverse-proxy domains and LAN IPs already trusted by DSH Web through `--trusted-host` still being rejected by Skills Manager's own loopback-only Host fence.
@@ -31,11 +49,3 @@ Published package: [`@michengai/dsh-skills-manager@0.1.28`](https://www.npmjs.co
31
49
  - Verified the reporter-provided cross-platform fnOS `trim-cli` Skill archive through the complete isolated import path.
32
50
 
33
51
  Published package: [`@michengai/dsh-skills-manager@0.1.27`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.27).
34
-
35
- ## 0.1.26 — 2026-08-27
36
-
37
- - Fixed browser folder imports that exceeded the former 16 MiB Base64 JSON request ceiling while still fitting within the documented 25 MiB decoded-content limit.
38
- - Added client-side checks for the existing 10 MiB ZIP, 5 MiB per-file, 25 MiB total, and 500-entry upload limits so oversized selections fail with a specific message before being read.
39
- - Replaced the large-string Base64 validation regex with stack-safe validation to prevent multi-MiB files from exhausting the JavaScript call stack.
40
-
41
- Published package: [`@michengai/dsh-skills-manager@0.1.26`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.26).
@@ -4,6 +4,24 @@
4
4
 
5
5
  以下记录最近发布的五个版本。
6
6
 
7
+ ## 未发布
8
+
9
+ ## 0.1.31 — 2026-08-30
10
+
11
+ - 新增活动 Session 工作区的项目 Skill 发现,覆盖项目根 `.dsh/skills` 与 `.agents/skills`,使用稳定的 workspace 专属身份并遵循官方优先级。
12
+ - 项目文件继续由 DSH 作用域 filesystem provider 管理,不重复注册 provider,也不复用用户级全局启停状态。
13
+ - 项目条目标记为“已发现”而不是“已加载”,展示 workspace 专属优先级/路径证据和手动刷新入口,并在宿主无法读取活动工作区时给出警告。
14
+ - 补充最近 Git 根、Session 工作区去重、单项目同名优先级、跨项目同名隔离、实时修改/删除、过期来源身份以及项目 Skill 根外链接目录等回归测试。
15
+ - 新增活动项目 `.dsh/skills` 的设置页创建与可恢复回收站删除,复用全局 Skill 的完整目录回滚/发布流程,同时保持项目 `.agents/skills` 只读。
16
+ - 回收站现在记录并展示原用户级或项目级来源;项目条目仅在当前活动 Session 可重新解析出同一不透明根身份时恢复。
17
+ - 新增项目 `.dsh/skills` 启停控件:只改写 Skill 自身调用策略字段,Session 作用域目录加载仍由 DSH 官方 provider 负责。
18
+ - 修复项目与 `$DSH_HOME` 位于不同磁盘时,移入回收站和恢复因 `EXDEV` 失败的问题,新增受保护的“复制后原子隐藏”降级流程。
19
+ - 主来源列表不再显示空项目根,但“创建技能”仍可选择它们;同时移除不受支持的项目来源总开关,不影响逐 Skill 启停。
20
+ - 修复可写项目发现会回落到无 Git 的 Session cwd、进而可能与用户级技能根重合的问题;项目来源现在必须找到真实 `.git` 祖先,与任一用户技能根重叠时直接隐藏并拒绝写入。
21
+ - 列表和详情不再跟随技能根本身的链接,包括项目 `.dsh`/`.agents` 容器及 Windows reparse point,同时保留既有的 bundle 子链接边界检查。
22
+
23
+ 发布包:[`@michengai/dsh-skills-manager@0.1.31`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.31)。
24
+
7
25
  ## 0.1.30 — 2026-08-28
8
26
 
9
27
  - 修复 DSH Web 已通过 `--trusted-host` 信任的反向代理域名或局域网 IP,仍被 Skills Manager 自身 loopback-only Host 校验拒绝的问题。
@@ -31,11 +49,3 @@
31
49
  - 使用反馈者提供的跨平台 fnOS `trim-cli` Skill 压缩包完成隔离环境全链路导入验证。
32
50
 
33
51
  发布包:[`@michengai/dsh-skills-manager@0.1.27`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.27)。
34
-
35
- ## 0.1.26 — 2026-08-27
36
-
37
- - 修复浏览器上传文件夹时,原始内容仍在 25 MiB 总量限制内,却因 Base64 JSON 超过旧 16 MiB 请求体上限而失败的问题。
38
- - 在浏览器读取内容前检查既有的 ZIP 10 MiB、单文件 5 MiB、总量 25 MiB 和 500 个条目限制,超限时直接显示具体原因。
39
- - 将大字符串 Base64 正则校验改为栈安全校验,避免数 MiB 文件耗尽 JavaScript 调用栈。
40
-
41
- 发布包:[`@michengai/dsh-skills-manager@0.1.26`](https://www.npmjs.com/package/@michengai/dsh-skills-manager/v/0.1.26)。
package/README.md CHANGED
@@ -23,10 +23,11 @@
23
23
  ## Features
24
24
 
25
25
  - Discover and load user-level skills from `.agents`, Codex, Claude, Gemini, and OpenCode into DSH.
26
+ - Discover project-level `.dsh/skills` and `.agents/skills` from active Session workspaces, grouped by project; project DSH skills can be enabled, disabled, created, moved to Trash, and restored while project Agent skills stay read-only.
26
27
  - Persist external source and skill toggles under `$DSH_HOME/skills-manager/state.json` without rewriting shared source files.
27
28
  - Inspect Markdown bodies, frontmatter, load state, duplicate shadowing, and format diagnostics in a source-first UI.
28
- - Create a local DSH skill from Settings or conversation; conversational writes require user approval.
29
- - Move DSH-local skills to recoverable Trash, then restore or permanently delete them.
29
+ - Create a user- or project-level DSH skill from Settings; conversational creation remains user-level and requires approval.
30
+ - Move user- and project-level DSH skills to recoverable Trash, then restore them to the original source or permanently delete them.
30
31
  - Import `.zip` archives, skill folders, or a single `SKILL.md` safely into `$DSH_HOME/skills`.
31
32
 
32
33
  ## Screenshots
@@ -51,7 +52,6 @@ This product can be installed independently or used through the desktop app or W
51
52
  | --- | --- |
52
53
  | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | The host runtime that provides models, sessions, tools, and the plugin system |
53
54
  | [DSH Codex Desktop](https://github.com/MichengAI/dsh-codex-desktop) | A ready-to-install desktop product with this product and the other five feature products built in |
54
- | [DSH Codex Suite](https://github.com/MichengAI/dsh-codex-ui/tree/main/packages/dsh-codex-suite) | A one-click suite for existing DSH Web environments that installs this product and the other five feature products |
55
55
  | Six feature products | [Codex UI](https://github.com/MichengAI/dsh-codex-ui) · [IM Connect](https://github.com/MichengAI/dsh-im-connect) · [Automation](https://github.com/MichengAI/dsh-automation) · [Skills Manager](https://github.com/MichengAI/dsh-skills-manager) · [Archive Manager](https://github.com/MichengAI/dsh-archive-manager) · [Agency Agents](https://github.com/MichengAI/dsh-agency-agents) |
56
56
 
57
57
  ## Prerequisites
@@ -137,12 +137,12 @@ Open **Settings → Skills**, then use the panel as follows:
137
137
  | --- | --- | --- |
138
138
  | Search or filter | Narrow by source, name, or description. | All sources |
139
139
  | Inspect details | Review body, frontmatter, path, format diagnostics, and duplicate shadowing. | All sources |
140
- | Enable or disable | DSH skills update their own invocation policy; external skills update manager-local state only. | All sources |
141
- | Create or import | Create in Settings, or import `.zip`, a folder containing `SKILL.md`, or one `SKILL.md`. | `$DSH_HOME/skills` |
140
+ | Enable or disable | User and project DSH skills update their own invocation policy; external user skills update manager-local state only. | User-level sources and active project DSH skills |
141
+ | Create or import | Choose a user or active project DSH destination when creating in Settings; imports remain user-level. | `$DSH_HOME/skills`, active `<project>/.dsh/skills` for creation |
142
142
  | Create from conversation | Let an Agent call `create_skill`; DSH asks for approval before writing. | `$DSH_HOME/skills` |
143
- | Delete and recover | Move to Trash, restore, or permanently delete in a second step. | DSH-local skills |
143
+ | Delete and recover | Move to Trash, restore to the original source, or permanently delete in a second step. | User and active-project DSH skills |
144
144
 
145
- > Toggling a shared source never changes its files; only DSH-local skills can move to Trash.
145
+ > Toggling a shared source never changes its files; only user or project DSH skills can move to Trash.
146
146
 
147
147
  Escape closes only the frontmost upload or confirmation dialog and leaves Settings open.
148
148
 
@@ -153,8 +153,17 @@ Escape closes only the frontmost upload or confirmation dialog and leaves Settin
153
153
  | `$DSH_HOME\skills` | Yes | Updates local invocation policy | Yes | Moves to Trash |
154
154
  | `$DSH_AGENTS_HOME\skills` | Yes | Manager state only | No | No |
155
155
  | `~/.codex/skills`, `~/.claude/skills`, `~/.gemini/skills`, `~/.config/opencode/skills` | Yes | Manager state only | No | No |
156
+ | `<project>/.dsh/skills` | Yes, for active Session workspaces | Updates the Skill's invocation policy; DSH's scoped provider remains responsible for loading | Create in Settings | Moves to Trash and restores to the original project |
157
+ | `<project>/.agents/skills` | Yes, for active Session workspaces | No; managed by DSH's scoped filesystem provider | No | No |
156
158
 
157
159
  - Enable, disable, and delete accept only one ordinary skill-name path segment.
160
+ - Project roots are derived only from active Session `cwd` values; client requests carry an opaque source key and cannot nominate an arbitrary workspace path.
161
+ - Project sources follow DSH's nearest-`.git` root convention and rank order (`project-dsh` 100 before `project-agents` 200). The manager re-scans for each state/detail request and does not register a duplicate project provider; writes are limited to invocation-policy toggles and create/Trash/restore under the active project's `.dsh/skills` root.
162
+ - 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
+ - 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
+ - Project writes reject linked `.dsh` or `.dsh/skills` directories so a repository cannot redirect creation, deletion, or restore outside its own project root.
165
+ - Project rows say **Discovered**, not **Loaded**: only DSH's Session-scoped catalog can establish model visibility. Use Refresh after IDE, Git, or shell changes; the official provider remains responsible for model-catalog watching and invalidation.
166
+ - 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.
158
167
  - Replacements copy to a temporary sibling path first and keep the original until that succeeds.
159
168
  - Every endpoint, including GET `/state`, accepts only a loopback `Host` or a canonical `host[:port]` that the DSH Web runtime already trusts through its LAN bind and `--trusted-host`; unknown hosts still receive 403.
160
169
  - Browser requests must also carry a same-origin `Origin` when present and must not be marked cross-site; write endpoints continue to require JSON and the DSH client request marker.
package/README.zh-CN.md CHANGED
@@ -23,10 +23,11 @@
23
23
  ## 功能概览
24
24
 
25
25
  - 自动发现并把 `.agents`、Codex、Claude、Gemini 和 OpenCode 的用户级技能真正加载进 DSH。
26
+ - 从活动 Session 工作区发现项目级 `.dsh/skills` 与 `.agents/skills` 并按项目分组;项目 DSH Skill 可启停、创建、移入回收站和恢复,项目 Agent Skill 保持只读。
26
27
  - 外部来源启停只写入 `$DSH_HOME\skills-manager\state.json`,绝不改写共享源文件。
27
28
  - 按来源折叠、搜索和筛选,并查看技能正文、frontmatter、加载状态、重名遮蔽与格式诊断。
28
- - 在设置页或对话中创建 DSH 本地技能;对话工具在写入前请求用户确认。
29
- - DSH 本地技能删除后先进入回收站,支持恢复和二次永久删除。
29
+ - 在设置页创建用户级或项目级 DSH Skill;对话创建仍为用户级,并在写入前请求确认。
30
+ - 用户级和项目级 DSH Skill 删除后先进入回收站,可恢复到原来源或二次永久删除。
30
31
  - 支持把 `.zip`、包含 `SKILL.md` 的技能文件夹或单个 `SKILL.md` 安全导入 `$DSH_HOME\skills`。
31
32
 
32
33
  ## 界面预览
@@ -51,7 +52,6 @@ DSH 本地技能移入回收站前需要确认;永久删除前仍可恢复:
51
52
  | --- | --- |
52
53
  | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | 本产品的运行宿主,提供模型、会话、工具和插件系统 |
53
54
  | [DSH Codex Desktop](https://github.com/MichengAI/dsh-codex-desktop) | 下载安装即用的桌面产品,已内置本产品和其他 5 个功能产品 |
54
- | [DSH Codex Suite](https://github.com/MichengAI/dsh-codex-ui/tree/main/packages/dsh-codex-suite) | 面向已有 DSH Web 环境的一键套件,会安装本产品和其他 5 个功能产品 |
55
55
  | 6 个功能产品 | [Codex UI](https://github.com/MichengAI/dsh-codex-ui) · [IM Connect](https://github.com/MichengAI/dsh-im-connect) · [Automation](https://github.com/MichengAI/dsh-automation) · [Skills Manager](https://github.com/MichengAI/dsh-skills-manager) · [Archive Manager](https://github.com/MichengAI/dsh-archive-manager) · [Agency Agents](https://github.com/MichengAI/dsh-agency-agents) |
56
56
 
57
57
  ## 前置条件
@@ -137,12 +137,12 @@ dsh --profile web --dump-config
137
137
  | --- | --- | --- |
138
138
  | 搜索或筛选 | 按来源、名称或简介收窄列表。 | 全部来源 |
139
139
  | 查看详情与诊断 | 查看正文、frontmatter、源文件路径、格式问题和重名遮蔽。 | 全部来源 |
140
- | 启用或停用 | DSH 技能更新自身调用策略;外部技能只更新 manager 本地状态。 | 全部来源 |
141
- | 创建或导入 | 在设置页创建,或导入 `.zip`、包含 `SKILL.md` 的文件夹、单个 `SKILL.md`。 | `$DSH_HOME\skills` |
140
+ | 启用或停用 | 用户级与项目级 DSH Skill 更新自身调用策略;外部用户技能只更新 manager 本地状态。 | 用户级来源与活动项目 DSH Skill |
141
+ | 创建或导入 | 在设置页创建时选择用户 DSH 或活动项目 DSH;导入仍为用户级。 | `$DSH_HOME\skills`,创建可选活动 `<project>/.dsh/skills` |
142
142
  | 从对话创建 | 让 Agent 调用 `create_skill`;写入前由 DSH 审批界面确认。 | `$DSH_HOME\skills` |
143
- | 删除与恢复 | 删除先进入回收站;可恢复或永久二次删除。 | DSH 本地技能 |
143
+ | 删除与恢复 | 删除先进入回收站;可恢复到原来源或永久二次删除。 | 用户级与活动项目级 DSH Skill |
144
144
 
145
- > 共享来源的启停不会修改源文件;只有 DSH 本地技能可以移入回收站。
145
+ > 共享来源的启停不会修改源文件;只有用户级或项目级 DSH Skill 可以移入回收站。
146
146
 
147
147
  按 ESC 只关闭最上层上传框或确认框,设置页会保持打开。
148
148
 
@@ -153,8 +153,17 @@ dsh --profile web --dump-config
153
153
  | `$DSH_HOME\skills` | 支持 | 改写本地调用策略 | 支持 | 进入回收站 |
154
154
  | `$DSH_AGENTS_HOME\skills` | 支持 | 仅写 manager 状态 | 不支持 | 不支持 |
155
155
  | `~/.codex/skills`、`~/.claude/skills`、`~/.gemini/skills`、`~/.config/opencode/skills` | 支持 | 仅写 manager 状态 | 不支持 | 不支持 |
156
+ | `<project>/.dsh/skills` | 支持活动 Session 工作区 | 改写 Skill 调用策略;仍由 DSH 作用域 provider 负责加载 | 设置页支持创建 | 进入回收站并恢复到原项目 |
157
+ | `<project>/.agents/skills` | 支持活动 Session 工作区 | 不支持;由 DSH 作用域 filesystem provider 管理 | 不支持 | 不支持 |
156
158
 
157
159
  - 启用、停用和删除只接受单个普通技能名称,目录穿越名称会被拒绝。
160
+ - 项目根只从活动 Session 的 `cwd` 推导;客户端只提交不透明来源 key,不能指定任意工作区路径。
161
+ - 项目来源遵循 DSH 最近 `.git` 项目根和固定优先级(`project-dsh` 100、`project-agents` 200)。管理器每次读取状态或详情时重新扫描且不重复注册项目 provider;写入仅限活动项目 `.dsh/skills` 下的调用策略启停、创建、回收与恢复。
162
+ - 当项目与 `$DSH_HOME` 位于不同磁盘时,回收站会降级为“复制后在源盘原子隐藏”;恢复使用反向的同一安全流程。
163
+ - 项目回收站条目保存原始不透明来源身份。仅当原项目仍由活动 Session 工作区提供时才允许恢复;客户端不能指定替代路径。
164
+ - 项目写入会拒绝链接形式的 `.dsh` 或 `.dsh/skills` 目录,避免仓库把创建、删除或恢复重定向到项目根之外。
165
+ - 项目条目标记为“已发现”而不是“已加载”:只有 DSH 的 Session 作用域目录才能证明模型可见性。IDE、Git 或 shell 改动后可点击“刷新”;模型目录 watcher 与 invalidation 仍由官方 provider 负责。
166
+ - 没有 Skill 的项目根不会出现在主来源列表中,但仍保留在“创建技能”的目标选择中,确保可以创建第一个项目 Skill。项目 DSH 只提供逐 Skill 启停,不提供整个项目来源总开关。
158
167
  - 覆盖前先复制到同目录临时路径;复制成功前不会改动现有技能。
159
168
  - 全部接口(含 GET `/state`)只接受 loopback `Host`,或 DSH Web runtime 已通过 LAN 绑定和 `--trusted-host` 明确信任的 `host[:port]`;未知 Host 继续返回 403。
160
169
  - 浏览器请求还必须满足同源 `Origin` 且不能标记为 cross-site;写入接口继续要求 JSON 与 DSH 客户端请求标记。
package/lib/client.js CHANGED
@@ -13,56 +13,56 @@ window.__ModuleLoader__.load({
13
13
  zh: {
14
14
  "title": "技能", "desc": "统一加载和管理本机 Agent Skills。",
15
15
  "btn.create": "创建技能", "btn.import": "导入", "btn.refresh": "刷新", "btn.cancel": "取消", "btn.close": "关闭", "btn.detail": "查看详情", "btn.trash": "移到回收站", "btn.restore": "恢复", "btn.delete.forever": "永久删除", "btn.file.pick": "选择文件", "btn.folder.pick": "选择文件夹", "btn.import.now": "安装", "btn.create.now": "创建技能", "btn.disable": "停用", "btn.enable": "启用", "btn.repair.enable": "修复并启用",
16
- "status.enabled": "已加载", "status.disabled": "已停用", "status.invalid": "诊断异常", "status.shadowed": "被覆盖", "status.readonly": "源文件只读", "status.manageable": "可管理", "status.source.on": "已加载", "status.source.off": "已停用", "status.bundle": "目录技能", "status.single": "单文件",
16
+ "status.enabled": "已加载", "status.discovered": "已发现", "status.disabled": "已停用", "status.invalid": "诊断异常", "status.shadowed": "被覆盖", "status.readonly": "源文件只读", "status.manageable": "可管理", "status.project": "项目级", "status.rank": "优先级 {rank}", "status.source.on": "已加载", "status.source.off": "已停用", "status.bundle": "目录技能", "status.single": "单文件",
17
17
  "summary.total": "{count} 个技能", "summary.loaded": "{count} 个已在 DSH 加载", "summary.disabled": "{count} 个已停用", "summary.issues": "{count} 个诊断项", "summary.group": "{count} 个技能", "table.skill": "技能名称与描述", "table.status": "DSH 加载状态",
18
18
  "filter.source": "来源", "filter.all": "全部来源", "filter.option": "{name}({count})", "search": "搜索", "search.placeholder": "搜索技能名称或描述", "search.clear": "清除搜索",
19
19
  "empty.search": "没有匹配的技能。", "empty.source": "该来源目录不存在或暂时没有技能。", "loading": "正在加载技能…", "note.missing": "未提供简介", "source.toggle": "启停来源", "skill.toggle": "启停技能",
20
20
  "source.external.note": "通过 Skills Manager 只读加载,启停不会改写源文件。", "source.dsh.note": "DSH 本地技能可创建、导入和移到回收站。",
21
21
  "detail.title": "技能详情", "detail.body": "正文", "detail.frontmatter": "Frontmatter", "detail.diagnostics": "诊断", "detail.path": "源文件", "detail.noIssues": "未发现诊断问题。",
22
- "create.title": "创建技能", "create.name": "名称", "create.name.placeholder": "例如 code-review-helper", "create.description": "简介", "create.description.placeholder": "一句话说明什么时候使用", "create.body": "正文(Markdown)", "create.body.placeholder": "写下技能要遵循的指令、步骤和边界…", "create.chat.note": "也可以在对话中直接说“创建一个 Skill”,DSH 会在写入前请求确认。",
22
+ "create.title": "创建技能", "create.target": "创建位置", "create.name": "名称", "create.name.placeholder": "例如 code-review-helper", "create.description": "简介", "create.description.placeholder": "一句话说明什么时候使用", "create.body": "正文(Markdown)", "create.body.placeholder": "写下技能要遵循的指令、步骤和边界…", "create.chat.note": "对话中的 create_skill 仍创建用户级 DSH Skill;项目 Skill 可在这里选择活动项目后创建。",
23
23
  "import.title": "导入技能", "upload.drop.title": "拖拽技能到此处", "upload.drop.copy": "支持 .zip、技能文件夹或单个 SKILL.md", "upload.selected": "{count} 个文件 · {size}", "upload.remove": "移除所选内容", "upload.requirements": "文件要求", "upload.requirement.skill": "压缩包或文件夹需包含 SKILL.md", "upload.requirement.frontmatter": "SKILL.md 需包含 YAML 格式的技能名称和描述", "upload.requirement.copy": "导入时复制完整内容,不修改原始来源", "upload.importing": "正在安装…", "status.selected": "已选择", "select.file.invalid": "请选择 .zip 或单个 SKILL.md。", "select.folder.invalid": "所选文件夹中没有找到 SKILL.md。", "error.browse.absolute": "目录路径必须是绝对路径:{path}", "error.browse.unreadable": "无法读取目录:{path}", "error.browse.notDirectory": "不是目录:{path}",
24
- "trash.title": "回收站", "trash.count": "{count} 个待处理技能", "trash.empty": "回收站为空。", "trash.deletedAt": "删除于 {time}",
25
- "confirm.trash.title": "移到回收站?", "confirm.trash.desc": "“{name}”将从 DSH 技能目录移入回收站,之后可以恢复。", "confirm.delete.title": "永久删除?", "confirm.delete.desc": "“{name}”将从回收站永久删除,无法恢复。",
24
+ "trash.title": "回收站", "trash.count": "{count} 个待处理技能", "trash.empty": "回收站为空。", "trash.deletedAt": "删除于 {time}", "trash.source": "来源:{source}",
25
+ "confirm.trash.title": "移到回收站?", "confirm.trash.desc": "“{name}”将从当前技能来源移入回收站,之后可以恢复到原位置。", "confirm.delete.title": "永久删除?", "confirm.delete.desc": "“{name}”将从回收站永久删除,无法恢复。",
26
26
  "result.created": "已创建技能:{name}", "result.imported": "导入完成:{names}", "result.importPartial": "已导入:{imported};已跳过同名技能:{skipped}", "result.importSkipped": "未导入任何技能;已跳过同名技能:{names}", "result.importEmpty": "未导入任何技能。", "result.importWarnings": "{result};警告:{warnings}", "result.restored": "已恢复技能:{name}", "result.trashed": "已移到回收站:{name}", "result.deleted": "已永久删除:{name}", "result.updated": "状态已更新。", "error.action": "操作失败:{error}",
27
- "warning.state.invalid": "技能管理器状态文件不可读;外部技能已安全停用,修复文件前不会覆盖状态:{path}", "warning.backupUncleaned": "旧版本备份未清理: {path}({error})",
28
- "error.root.readonly": "该来源不允许{action}", "error.root.unknown": "未知技能来源:{root}", "error.skill.notFound": "技能不存在: {name}", "error.skill.noFrontmatter": "技能缺少完整 frontmatter,无法{action}: {name}",
27
+ "warning.state.invalid": "技能管理器状态文件不可读;外部技能已安全停用,修复文件前不会覆盖状态:{path}", "warning.backupUncleaned": "旧版本备份未清理: {path}({error})", "warning.project.unavailable": "无法从宿主读取活动工作区,项目技能未显示:{path}",
28
+ "error.root.readonly": "该来源不允许{action}", "error.root.unknown": "未知技能来源:{root}", "error.root.unsafe": "项目技能目录不安全,拒绝写入:{path}", "error.skill.notFound": "技能不存在: {name}", "error.skill.noFrontmatter": "技能缺少完整 frontmatter,无法{action}: {name}",
29
29
  "error.source.notFound": "路径不存在: {path}", "error.source.symlink": "不支持包含符号链接的 skill 来源: {path}", "error.source.unrecognized": "无法识别的 skill 来源: {path}", "error.source.tooDeep": "skill 来源目录层级超过 {depth} 层: {path}",
30
30
  "error.import.overlap": "导入来源不能与 DSH 技能目录相同、包含或位于其中", "error.import.emptySource": "目录下未找到任何 skill 条目: {path}", "error.import.invalidName": "无法生成合法 kebab-case 名称(原始名: {name})", "error.import.duplicateName": "批量来源中存在多个同名技能: {name}", "error.import.failed": "导入失败", "error.import.rollbackFailed": "覆盖导入回滚失败,备份保留在: {path}({error})",
31
31
  "error.upload.path": "上传内容包含非法路径:{path}", "error.upload.encoding": "上传内容编码无效", "error.upload.empty": "上传内容为空", "error.upload.tooMany": "上传文件过多,最多 {limit} 个", "error.upload.tooLarge": "上传内容过大,限制为 {limit} 字节", "error.upload.archiveTooLarge": "ZIP 压缩包过大,限制为 {limit} 字节", "error.upload.duplicate": "上传内容包含重复路径:{path}", "error.upload.zipInvalid": "ZIP 压缩包无法解压",
32
- "error.trash.notFound": "回收站条目不存在: {id}", "error.trash.conflict": "无法恢复,同名技能已存在: {name}", "error.trash.invalid": "回收站条目路径非法: {id}", "error.trash.rollbackFailed": "移入回收站回滚失败,未恢复内容保留在: {path}({error})",
32
+ "error.trash.notFound": "回收站条目不存在: {id}", "error.trash.conflict": "无法恢复,同名技能已存在: {name}", "error.trash.invalid": "回收站条目路径非法: {id}", "error.trash.projectUnavailable": "原项目当前不在活动工作区中,无法恢复:{path}", "error.trash.rollbackFailed": "移入回收站回滚失败,未恢复内容保留在: {path}({error})",
33
33
  "error.state.invalid": "技能管理器状态文件不可读,已拒绝覆盖:{path}",
34
34
  "error.create.descriptionRequired": "技能简介不能为空", "error.create.bodyRequired": "技能正文不能为空", "error.create.tooLarge": "技能内容过长", "error.create.conflict": "同名技能已存在: {name}",
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
- "action.enable": "启用", "action.disable": "停用", "action.delete": "删除", "action.toggle": "启用或停用",
38
- "root.dsh": "DSH 技能", "root.agents": "公共 Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
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"
39
39
  },
40
40
  en: {
41
41
  "title": "Skills", "desc": "Load and manage Agent Skills on this computer in one place.",
42
42
  "btn.create": "Create skill", "btn.import": "Import", "btn.refresh": "Refresh", "btn.cancel": "Cancel", "btn.close": "Close", "btn.detail": "View details", "btn.trash": "Move to trash", "btn.restore": "Restore", "btn.delete.forever": "Delete forever", "btn.file.pick": "Choose file", "btn.folder.pick": "Choose folder", "btn.import.now": "Install", "btn.create.now": "Create skill", "btn.disable": "Disable", "btn.enable": "Enable", "btn.repair.enable": "Repair & enable",
43
- "status.enabled": "Loaded", "status.disabled": "Disabled", "status.invalid": "Needs attention", "status.shadowed": "Shadowed", "status.readonly": "Source read-only", "status.manageable": "Manageable", "status.source.on": "Loaded", "status.source.off": "Disabled", "status.bundle": "Bundle", "status.single": "Single file",
43
+ "status.enabled": "Loaded", "status.discovered": "Discovered", "status.disabled": "Disabled", "status.invalid": "Needs attention", "status.shadowed": "Shadowed", "status.readonly": "Source read-only", "status.manageable": "Manageable", "status.project": "Project scoped", "status.rank": "Rank {rank}", "status.source.on": "Loaded", "status.source.off": "Disabled", "status.bundle": "Bundle", "status.single": "Single file",
44
44
  "summary.total": "{count} skills", "summary.loaded": "{count} loaded in DSH", "summary.disabled": "{count} disabled", "summary.issues": "{count} diagnostics", "summary.group": "{count} skills", "table.skill": "Skill name and description", "table.status": "DSH load status",
45
45
  "filter.source": "Source", "filter.all": "All sources", "filter.option": "{name} ({count})", "search": "Search", "search.placeholder": "Search skill names or descriptions", "search.clear": "Clear search",
46
46
  "empty.search": "No matching skills.", "empty.source": "This source does not exist or has no skills yet.", "loading": "Loading skills…", "note.missing": "No description provided", "source.toggle": "Toggle source", "skill.toggle": "Toggle skill",
47
47
  "source.external.note": "Loaded read-only through Skills Manager; toggles never rewrite source files.", "source.dsh.note": "Local DSH skills can be created, imported, and moved to trash.",
48
48
  "detail.title": "Skill details", "detail.body": "Body", "detail.frontmatter": "Frontmatter", "detail.diagnostics": "Diagnostics", "detail.path": "Source file", "detail.noIssues": "No diagnostic issues found.",
49
- "create.title": "Create skill", "create.name": "Name", "create.name.placeholder": "e.g. code-review-helper", "create.description": "Description", "create.description.placeholder": "One sentence describing when to use it", "create.body": "Body (Markdown)", "create.body.placeholder": "Write the instructions, steps, and boundaries…", "create.chat.note": "You can also say “create a Skill” in chat; DSH asks for confirmation before writing.",
49
+ "create.title": "Create skill", "create.target": "Create in", "create.name": "Name", "create.name.placeholder": "e.g. code-review-helper", "create.description": "Description", "create.description.placeholder": "One sentence describing when to use it", "create.body": "Body (Markdown)", "create.body.placeholder": "Write the instructions, steps, and boundaries…", "create.chat.note": "The conversational create_skill tool still creates a user-level DSH Skill; choose an active project here for a project Skill.",
50
50
  "import.title": "Import skill", "upload.drop.title": "Drop a skill here", "upload.drop.copy": "Supports .zip, a skill folder, or one SKILL.md", "upload.selected": "{count} files · {size}", "upload.remove": "Remove selection", "upload.requirements": "File requirements", "upload.requirement.skill": "Archives and folders must contain SKILL.md", "upload.requirement.frontmatter": "SKILL.md must include a YAML name and description", "upload.requirement.copy": "Import copies all content and never modifies the source", "upload.importing": "Installing…", "status.selected": "Selected", "select.file.invalid": "Choose a .zip archive or one SKILL.md.", "select.folder.invalid": "No SKILL.md was found in the selected folder.", "error.browse.absolute": "Folder path must be absolute: {path}", "error.browse.unreadable": "Could not read folder: {path}", "error.browse.notDirectory": "Not a folder: {path}",
51
- "trash.title": "Trash", "trash.count": "{count} skills pending", "trash.empty": "Trash is empty.", "trash.deletedAt": "Deleted {time}",
52
- "confirm.trash.title": "Move to trash?", "confirm.trash.desc": "“{name}” will move out of the DSH skills directory and can be restored later.", "confirm.delete.title": "Delete forever?", "confirm.delete.desc": "“{name}” will be permanently deleted from trash and cannot be recovered.",
51
+ "trash.title": "Trash", "trash.count": "{count} skills pending", "trash.empty": "Trash is empty.", "trash.deletedAt": "Deleted {time}", "trash.source": "Source: {source}",
52
+ "confirm.trash.title": "Move to trash?", "confirm.trash.desc": "“{name}” will move out of its current skill source and can be restored to the same location later.", "confirm.delete.title": "Delete forever?", "confirm.delete.desc": "“{name}” will be permanently deleted from trash and cannot be recovered.",
53
53
  "result.created": "Created skill: {name}", "result.imported": "Import complete: {names}", "result.importPartial": "Imported: {imported}; skipped existing skills: {skipped}", "result.importSkipped": "No skills were imported; existing skills were skipped: {names}", "result.importEmpty": "No skills were imported.", "result.importWarnings": "{result}; warnings: {warnings}", "result.restored": "Restored skill: {name}", "result.trashed": "Moved to trash: {name}", "result.deleted": "Permanently deleted: {name}", "result.updated": "Status updated.", "error.action": "Action failed: {error}",
54
- "warning.state.invalid": "The manager state file could not be read; external skills are disabled and state writes are blocked until it is repaired: {path}", "warning.backupUncleaned": "Old version backup was not cleaned up: {path} ({error})",
55
- "error.root.readonly": "The shared Agent skills directory does not allow {action}", "error.root.unknown": "Unknown skill source: {root}", "error.skill.notFound": "Skill not found: {name}", "error.skill.noFrontmatter": "Skill lacks complete frontmatter, cannot {action}: {name}",
54
+ "warning.state.invalid": "The manager state file could not be read; external skills are disabled and state writes are blocked until it is repaired: {path}", "warning.backupUncleaned": "Old version backup was not cleaned up: {path} ({error})", "warning.project.unavailable": "The active workspace could not be read from the host, so its project skills are hidden: {path}",
55
+ "error.root.readonly": "This source does not allow {action}", "error.root.unknown": "Unknown skill source: {root}", "error.root.unsafe": "The project skill directory is unsafe, so the write was refused: {path}", "error.skill.notFound": "Skill not found: {name}", "error.skill.noFrontmatter": "Skill lacks complete frontmatter, cannot {action}: {name}",
56
56
  "error.source.notFound": "Path does not exist: {path}", "error.source.symlink": "Skill sources containing symbolic links are not supported: {path}", "error.source.unrecognized": "Unrecognized skill source: {path}", "error.source.tooDeep": "Skill source directory depth exceeds {depth} levels: {path}",
57
57
  "error.import.overlap": "Import source cannot be the same as, contain, or be inside the DSH skills directory", "error.import.emptySource": "No skill entries found in the directory: {path}", "error.import.invalidName": "Cannot generate a valid kebab-case name (original: {name})", "error.import.duplicateName": "Batch source contains duplicate skill names: {name}", "error.import.failed": "Import failed", "error.import.rollbackFailed": "Overwrite import rollback failed; backups kept at: {path} ({error})",
58
58
  "error.upload.path": "Upload contains an invalid path: {path}", "error.upload.encoding": "Upload encoding is invalid", "error.upload.empty": "Upload is empty", "error.upload.tooMany": "Too many uploaded files; maximum {limit}", "error.upload.tooLarge": "Upload is too large; limit {limit} bytes", "error.upload.archiveTooLarge": "ZIP archive is too large; limit {limit} bytes", "error.upload.duplicate": "Upload contains a duplicate path: {path}", "error.upload.zipInvalid": "ZIP archive could not be extracted",
59
- "error.trash.notFound": "Trash item not found: {id}", "error.trash.conflict": "Cannot restore because a skill with the same name exists: {name}", "error.trash.invalid": "Invalid trash item path: {id}", "error.trash.rollbackFailed": "Move-to-trash rollback failed; unrecovered content was kept at: {path} ({error})",
59
+ "error.trash.notFound": "Trash item not found: {id}", "error.trash.conflict": "Cannot restore because a skill with the same name exists: {name}", "error.trash.invalid": "Invalid trash item path: {id}", "error.trash.projectUnavailable": "The original project is not an active workspace, so this skill cannot be restored: {path}", "error.trash.rollbackFailed": "Move-to-trash rollback failed; unrecovered content was kept at: {path} ({error})",
60
60
  "error.state.invalid": "The manager state file could not be read, so overwriting it was refused: {path}",
61
61
  "error.create.descriptionRequired": "Skill description is required", "error.create.bodyRequired": "Skill body is required", "error.create.tooLarge": "Skill content is too large", "error.create.conflict": "A skill with the same name already exists: {name}",
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
- "action.enable": "enable", "action.disable": "disable", "action.delete": "delete", "action.toggle": "enabling or disabling",
65
- "root.dsh": "DSH skills", "root.agents": "Shared Agent", "root.codex": "Codex", "root.claude": "Claude", "root.gemini": "Gemini", "root.opencode": "OpenCode"
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"
66
66
  }
67
67
  };
68
68
 
@@ -90,6 +90,7 @@ window.__ModuleLoader__.load({
90
90
  function parseApiResponse(response) { return response.json().catch(function () { var error = new Error("non-json response"); error.code = "error.proto.nonJson"; error.params = { status: response.status }; throw error; }).then(function (payload) { if (!response.ok || payload.ok === false) throw payload; return payload.data; }); }
91
91
  function callApi(path, options) { return fetch("/api/dsh-skills-manager" + path, options).then(parseApiResponse); }
92
92
  function isSkillEnabled(skill) { if (skill.loaded !== undefined) return skill.loaded === true; return skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false; }
93
+ function rootDisplayName(t, root) { var base = translateOrFallback(t, "root." + (root.localeKey || root.kind || root.key), root.label); return root.projectName ? base + " · " + root.projectName : base; }
93
94
  function summarizeImportResult(t, data) {
94
95
  var importedItems = data && data.imported || [];
95
96
  var imported = importedItems.map(function (item) { return item.name; });
@@ -112,6 +113,7 @@ window.__ModuleLoader__.load({
112
113
  function normalizeSkillQuery(query) { return String(query == null ? "" : query).trim().toLowerCase(); }
113
114
  function matchSkillQuery(skill, query) { var q = normalizeSkillQuery(query); if (!q) return true; return [skill.name, skill.declaredName, skill.description, skill.kind, skill.kindLabel, skill.statusLabel, skill.rootKey, skill.rootLabel].some(function (value) { return String(value == null ? "" : value).toLowerCase().includes(q); }); }
114
115
  function filterSkills(list, options) { var rootKey = options && options.rootKey != null ? options.rootKey : ""; return list.filter(function (skill) { return (!rootKey || skill.rootKey === rootKey) && matchSkillQuery(skill, options && options.query); }); }
116
+ function visibleSkillRoots(roots) { return (roots || []).filter(function (root) { return root.scope !== "project" || (root.skills || []).length > 0; }); }
115
117
  var MAX_UPLOAD_ARCHIVE_BYTES = 32 << 20, MAX_UPLOAD_ENTRY_BYTES = 32 << 20, MAX_UPLOAD_TOTAL_BYTES = 64 << 20, MAX_UPLOAD_ENTRIES = 1000;
116
118
  function uploadFilePath(file) { return String(file && (file._dssmPath || file.webkitRelativePath || file.name) || "").replace(/\\/g, "/"); }
117
119
  function inspectUploadSelection(files) {
@@ -158,7 +160,7 @@ window.__ModuleLoader__.load({
158
160
  var es = react.useState({ codex: true }), expanded = es[0], setExpanded = es[1];
159
161
  var ms = react.useState(null), modal = ms[0], setModal = ms[1];
160
162
  var rs = react.useState(null), result = rs[0], setResult = rs[1];
161
- var fms = react.useState({ name: "", description: "", body: "" }), form = fms[0], setForm = fms[1];
163
+ var fms = react.useState({ root: "dsh", name: "", description: "", body: "" }), form = fms[0], setForm = fms[1];
162
164
  var us = react.useState(null), upload = us[0], setUpload = us[1];
163
165
  var ds = react.useState(null), detail = ds[0], setDetail = ds[1];
164
166
  var inflightRef = react.useRef(false);
@@ -170,27 +172,30 @@ window.__ModuleLoader__.load({
170
172
  function post(path, body, successKey, successParams) { if (inflightRef.current) return Promise.reject({ error: "operation already in progress" }); inflightRef.current = true; setBusy(true); setResult(null); return callApi(path, { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify(body || {}) }).then(function (data) { setResult({ ok: true, text: successKey ? t(successKey, successParams || data || {}) : t("result.updated") }); return refresh(true).then(function () { return data; }); }).catch(function (error) { setResult({ ok: false, text: t("error.action", { error: translateError(t, error) }) }); throw error; }).finally(function () { inflightRef.current = false; setBusy(false); }); }
171
173
  function openDetail(root, skill) { setBusy(true); setDetail(null); setModal("detail"); callApi("/detail", { method: "POST", headers: MUTATION_HEADERS, body: JSON.stringify({ root: root.key, name: skill.name }) }).then(setDetail).catch(function (error) { setResult({ ok: false, text: t("error.action", { error: translateError(t, error) }) }); setModal(null); }).finally(function () { setBusy(false); }); }
172
174
  function updateForm(key, value) { setForm(Object.assign({}, form, { [key]: value })); }
173
- function submitCreate() { post("/create", form, null).then(function (data) { setModal(null); setForm({ name: "", description: "", body: "" }); setResult({ ok: true, text: t("result.created", { name: data.name }) }); }).catch(function () {}); }
175
+ function submitCreate() { post("/create", form, null).then(function (data) { setModal(null); setForm({ root: "dsh", name: "", description: "", body: "" }); setResult({ ok: true, text: t("result.created", { name: data.name }) }); }).catch(function () {}); }
174
176
  function selectUploadFiles(files) { pickerOpenRef.current = false; var selected = inspectUploadSelection(files); if (!selected) return; if (selected.error) { setUpload(null); setResult({ ok: false, text: translateError(t, selected.error) }); return; } setUpload(selected); setResult(null); }
175
177
  function openNativePicker(ref) { if (busy || pickerOpenRef.current || !ref.current) return; pickerOpenRef.current = true; ref.current.value = ""; function release() { setTimeout(function () { pickerOpenRef.current = false; }, 0); } window.addEventListener("focus", release, { once: true }); ref.current.click(); setTimeout(function () { pickerOpenRef.current = false; }, 30000); }
176
178
  function submitImport() { if (!upload) return; buildUploadPayload(upload).then(function (payload) { return post("/upload", payload, null); }).then(function (data) { var summary = summarizeImportResult(t, data); setResult({ ok: summary.ok, warning: summary.warning, text: summary.text }); if (summary.imported) { setModal(null); setUpload(null); } }).catch(function () {}); }
177
- var data = snapshot.data || { roots: [], trash: [], summary: { total: 0, loaded: 0, disabled: 0, issues: 0 } }, roots = data.roots || [];
178
- var options = [{ value: "", label: t("filter.all") }].concat(roots.map(function (root) { return { value: root.key, label: t("filter.option", { name: translateOrFallback(t, "root." + root.key, root.label), count: root.count == null ? root.skills.length : root.count }) }; }));
179
+ var data = snapshot.data || { roots: [], trash: [], summary: { total: 0, loaded: 0, disabled: 0, issues: 0 } }, allRoots = data.roots || [], roots = visibleSkillRoots(allRoots), activeSource = roots.some(function (root) { return root.key === source; }) ? source : "";
180
+ var createRoots = allRoots.filter(function (root) { return root.mutable === true; }), createOptions = createRoots.map(function (root) { return { value: root.key, label: rootDisplayName(t, root) }; }); if (!createOptions.length) createOptions.push({ value: "dsh", label: t("root.dsh") });
181
+ function openCreate() { var selectedRoot = createRoots.some(function (root) { return root.key === activeSource; }) ? activeSource : "dsh"; setForm(Object.assign({}, form, { root: selectedRoot })); setModal("create"); }
182
+ function trashRootLabel(item) { return item.root && item.root.scope === "project" ? t("root.projectDsh") + " · " + (item.root.projectName || item.root.projectRoot) : t("root.dsh"); }
183
+ var options = [{ value: "", label: t("filter.all") }].concat(roots.map(function (root) { return { value: root.key, label: t("filter.option", { name: rootDisplayName(t, root), count: root.count == null ? root.skills.length : root.count }) }; }));
179
184
 
180
185
  function renderSkill(root, skill) {
181
- var enabled = isSkillEnabled(skill), repairable = root.mutable && skill.hasFrontmatter && skill.invocationPolicyValid === false && !skill.shadowedBy, key = skill.shadowedBy ? "status.shadowed" : skill.loadable === false || !skill.invocationPolicyValid ? "status.invalid" : enabled ? "status.enabled" : "status.disabled", cls = skill.shadowedBy ? "dssm-shadowed" : enabled ? "dssm-loaded" : "dssm-disabled";
182
- return h("div", { key: skill.name, className: "dssm-row" }, h("div", { className: "dssm-main" }, h("div", { className: "dssm-name" }, skill.declaredName || skill.name), h("div", { className: "dssm-note" }, skill.description || t("note.missing"))), h("div", { className: "dssm-tags" }, h("span", { className: "dssm-tag" }, translateOrFallback(t, "root." + root.key, root.label)), !root.mutable ? h("span", { className: "dssm-tag" }, t("status.readonly")) : null), h("div", { className: "dssm-status " + cls }, t(key)), h("div", { className: "dssm-row-actions" }, repairable ? h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", disabled: busy, onClick: function () { post("/enable", { root: root.key, name: skill.name }); } }, t("btn.repair.enable")) : h(Switch, { on: enabled, disabled: busy || !!skill.shadowedBy || skill.loadable === false, label: t("skill.toggle") + " " + skill.name, onClick: function () { post(enabled ? "/disable" : "/enable", { root: root.key, name: skill.name }); } }), h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", onClick: function () { openDetail(root, skill); } }, t("btn.detail")), root.mutable ? h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", onClick: function () { setModal({ type: "trash-confirm", root: root.key, name: skill.name }); } }, t("btn.trash")) : null));
186
+ var enabled = isSkillEnabled(skill), repairable = root.mutable && skill.hasFrontmatter && skill.invocationPolicyValid === false && !skill.shadowedBy, key = skill.shadowedBy ? "status.shadowed" : skill.loadable === false || !skill.invocationPolicyValid ? "status.invalid" : root.scope === "project" && enabled ? "status.discovered" : enabled ? "status.enabled" : "status.disabled", cls = skill.shadowedBy ? "dssm-shadowed" : enabled ? "dssm-loaded" : "dssm-disabled";
187
+ return h("div", { key: skill.name, className: "dssm-row" }, h("div", { className: "dssm-main" }, h("div", { className: "dssm-name" }, skill.declaredName || skill.name), h("div", { className: "dssm-note" }, skill.description || t("note.missing"))), h("div", { className: "dssm-tags" }, h("span", { className: "dssm-tag" }, rootDisplayName(t, root)), !root.mutable ? h("span", { className: "dssm-tag" }, t("status.readonly")) : null), h("div", { className: "dssm-status " + cls }, t(key)), h("div", { className: "dssm-row-actions" }, repairable ? h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", disabled: busy, onClick: function () { post("/enable", { root: root.key, name: skill.name }); } }, t("btn.repair.enable")) : root.toggleable !== false ? h(Switch, { on: enabled, disabled: busy || !!skill.shadowedBy || skill.loadable === false, label: t("skill.toggle") + " " + skill.name, onClick: function () { post(enabled ? "/disable" : "/enable", { root: root.key, name: skill.name }); } }) : null, h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", onClick: function () { openDetail(root, skill); } }, t("btn.detail")), root.mutable ? h("button", { type: "button", className: "dssm-btn dssm-btn-quiet", onClick: function () { setModal({ type: "trash-confirm", root: root.key, name: skill.name }); } }, t("btn.trash")) : null));
183
188
  }
184
189
  function renderRoot(root) {
185
- if (source && source !== root.key) return null;
186
- var filtered = root.skills.filter(function (skill) { return matchSkillQuery(Object.assign({}, skill, { rootKey: root.key, rootLabel: root.label }), query); }); if (query && !filtered.length) return null; var open = !!expanded[root.key] || !!query;
187
- return h("section", { key: root.key, className: "dssm-source" }, h("div", { className: "dssm-source-head" }, h("button", { type: "button", className: "dssm-source-head-main", "aria-expanded": open, onClick: function () { setExpanded(Object.assign({}, expanded, { [root.key]: !open })); } }, h("span", { className: "dssm-source-title" }, translateOrFallback(t, "root." + root.key, root.label)), h("span", { className: "dssm-count" }, t("summary.group", { count: root.count == null ? root.skills.length : root.count })), h("span", { className: "dssm-tag " + (root.key === "dsh" ? "" : root.enabled ? "dssm-tag-on" : "dssm-tag-off") }, root.key === "dsh" ? t("status.manageable") : t(root.enabled ? "status.source.on" : "status.source.off")), h("span", { className: "dssm-path", title: root.path }, root.path)), root.key !== "dsh" ? h("span", { className: "dssm-source-actions" }, h(Switch, { on: root.enabled, disabled: busy, label: t("source.toggle") + " " + root.label, onClick: function () { post(root.enabled ? "/source-disable" : "/source-enable", { root: root.key }); } })) : null), open ? h("div", { className: "dssm-source-body" }, filtered.length ? h(react.Fragment, null, h("div", { className: "dssm-table-head" }, h("span", null, t("table.skill")), h("span", null, t("filter.source")), h("span", null, t("table.status")), h("span", null, "")), filtered.map(function (skill) { return renderSkill(root, skill); })) : h("div", { className: "dssm-empty" }, query ? t("empty.search") : t("empty.source"))) : null);
190
+ if (activeSource && activeSource !== root.key) return null;
191
+ var displayName = rootDisplayName(t, root), filtered = root.skills.filter(function (skill) { return matchSkillQuery(Object.assign({}, skill, { rootKey: root.key, rootLabel: displayName }), query); }); if (query && !filtered.length) return null; var open = !!expanded[root.key] || !!query;
192
+ return h("section", { key: root.key, className: "dssm-source" }, h("div", { className: "dssm-source-head" }, h("button", { type: "button", className: "dssm-source-head-main", "aria-expanded": open, onClick: function () { setExpanded(Object.assign({}, expanded, { [root.key]: !open })); } }, h("span", { className: "dssm-source-title" }, displayName), h("span", { className: "dssm-count" }, t("summary.group", { count: root.count == null ? root.skills.length : root.count })), h("span", { className: "dssm-tag " + (root.scope === "project" ? "dssm-tag-on" : root.key === "dsh" ? "" : root.enabled ? "dssm-tag-on" : "dssm-tag-off") }, root.scope === "project" ? t("status.project") : root.key === "dsh" ? t("status.manageable") : t(root.enabled ? "status.source.on" : "status.source.off")), root.scope === "project" ? h("span", { className: "dssm-tag" }, t("status.rank", { rank: root.rank })) : null, h("span", { className: "dssm-path", title: root.path }, root.path)), root.scope !== "project" && root.key !== "dsh" && root.toggleable !== false ? h("span", { className: "dssm-source-actions" }, h(Switch, { on: root.enabled, disabled: busy, label: t("source.toggle") + " " + displayName, onClick: function () { post(root.enabled ? "/source-disable" : "/source-enable", { root: root.key }); } })) : null), open ? h("div", { className: "dssm-source-body" }, filtered.length ? h(react.Fragment, null, h("div", { className: "dssm-table-head" }, h("span", null, t("table.skill")), h("span", null, t("filter.source")), h("span", null, t("table.status")), h("span", null, "")), filtered.map(function (skill) { return renderSkill(root, skill); })) : h("div", { className: "dssm-empty" }, query ? t("empty.search") : t("empty.source"))) : null);
188
193
  }
189
194
 
190
195
  var summary = data.summary || { total: 0, loaded: 0, disabled: 0, issues: 0 };
191
- var content = [h("style", { key: "css" }, CSS), h("div", { key: "head", className: "dssm-head" }, h("div", null, h("h2", { className: "dssm-title" }, t("title")), h("p", { className: "dssm-desc" }, t("desc"))), h("div", { className: "dssm-actions" }, h("button", { type: "button", className: "dssm-btn", onClick: function () { setModal("create"); } }, t("btn.create")), h("button", { type: "button", className: "dssm-btn dssm-btn-secondary", onClick: function () { setResult(null); setUpload(null); setModal("import"); } }, t("btn.import")))), h("div", { key: "summary", className: "dssm-summary" }, [[summary.total, "summary.total"], [summary.loaded, "summary.loaded"], [summary.disabled, "summary.disabled"]].map(function (item) { return h("div", { key: item[1], className: "dssm-stat" }, h("strong", null, item[0]), t(item[1], { count: item[0] }).replace(String(item[0]), "")); })), h("div", { key: "filters", className: "dssm-filters" }, h("input", { className: "dssm-control dssm-search", value: query, "aria-label": t("search"), placeholder: t("search.placeholder"), onChange: function (e) { setQuery(e.target.value); } }), h("div", { className: "dssm-source-filter" }, h(SourceSelect, { value: source, options: options, onChange: setSource }))), result && modal !== "import" ? h("div", { key: "result", className: "dssm-feedback" + (result.warning ? " dssm-warning" : result.ok ? "" : " dssm-error") }, result.text) : null].concat((data.warnings || []).map(function (warning, index) { return h("div", { key: "warning-" + index, className: "dssm-feedback dssm-warning", role: "alert" }, translateError(t, warning)); }), [snapshot.error ? h("div", { key: "error", className: "dssm-feedback dssm-error" }, snapshot.error) : null, snapshot.loading && !snapshot.data ? h("div", { key: "loading", className: "dssm-empty" }, t("loading")) : h("div", { key: "sources", className: "dssm-sources" }, roots.map(renderRoot)), h("button", { key: "trash", type: "button", className: "dssm-trash-row", onClick: function () { setModal("trash"); } }, h("span", null, t("trash.title")), h("span", { className: "dssm-trash-count" }, (data.trash || []).length))]);
196
+ var content = [h("style", { key: "css" }, CSS), h("div", { key: "head", className: "dssm-head" }, h("div", null, h("h2", { className: "dssm-title" }, t("title")), h("p", { className: "dssm-desc" }, t("desc"))), h("div", { className: "dssm-actions" }, h("button", { type: "button", className: "dssm-btn dssm-btn-secondary", disabled: busy || snapshot.loading, onClick: function () { refresh(false); } }, t("btn.refresh")), h("button", { type: "button", className: "dssm-btn", onClick: openCreate }, t("btn.create")), h("button", { type: "button", className: "dssm-btn dssm-btn-secondary", onClick: function () { setResult(null); setUpload(null); setModal("import"); } }, t("btn.import")))), h("div", { key: "summary", className: "dssm-summary" }, [[summary.total, "summary.total"], [summary.loaded, "summary.loaded"], [summary.disabled, "summary.disabled"]].map(function (item) { return h("div", { key: item[1], className: "dssm-stat" }, h("strong", null, item[0]), t(item[1], { count: item[0] }).replace(String(item[0]), "")); })), h("div", { key: "filters", className: "dssm-filters" }, h("input", { className: "dssm-control dssm-search", value: query, "aria-label": t("search"), placeholder: t("search.placeholder"), onChange: function (e) { setQuery(e.target.value); } }), h("div", { className: "dssm-source-filter" }, h(SourceSelect, { value: activeSource, options: options, onChange: setSource }))), result && modal !== "import" ? h("div", { key: "result", className: "dssm-feedback" + (result.warning ? " dssm-warning" : result.ok ? "" : " dssm-error") }, result.text) : null].concat((data.warnings || []).map(function (warning, index) { return h("div", { key: "warning-" + index, className: "dssm-feedback dssm-warning", role: "alert" }, translateError(t, warning)); }), [snapshot.error ? h("div", { key: "error", className: "dssm-feedback dssm-error" }, snapshot.error) : null, snapshot.loading && !snapshot.data ? h("div", { key: "loading", className: "dssm-empty" }, t("loading")) : h("div", { key: "sources", className: "dssm-sources" }, roots.map(renderRoot)), h("button", { key: "trash", type: "button", className: "dssm-trash-row", onClick: function () { setModal("trash"); } }, h("span", null, t("trash.title")), h("span", { className: "dssm-trash-count" }, (data.trash || []).length))]);
192
197
 
193
- if (modal === "create") content.push(h(Modal, { key: "create", title: t("create.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dssm-form" }, [["name", "create.name", "create.name.placeholder"], ["description", "create.description", "create.description.placeholder"]].map(function (field) { return h("label", { key: field[0], className: "dssm-field" }, h("span", { className: "dssm-label" }, t(field[1])), h("input", { className: "dssm-control", value: form[field[0]], placeholder: t(field[2]), onChange: function (e) { updateForm(field[0], e.target.value); } })); }), h("label", { className: "dssm-field" }, h("span", { className: "dssm-label" }, t("create.body")), h("textarea", { className: "dssm-control", value: form.body, placeholder: t("create.body.placeholder"), onChange: function (e) { updateForm("body", e.target.value); } })), h("p", { className: "dssm-help" }, t("create.chat.note"))), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setModal(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: busy || !form.name.trim() || !form.description.trim() || !form.body.trim(), onClick: submitCreate }, t("btn.create.now")))));
198
+ if (modal === "create") content.push(h(Modal, { key: "create", title: t("create.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dssm-form" }, h("label", { className: "dssm-field" }, h("span", { className: "dssm-label" }, t("create.target")), h(SourceSelect, { value: form.root, options: createOptions, onChange: function (value) { updateForm("root", value); } })), [["name", "create.name", "create.name.placeholder"], ["description", "create.description", "create.description.placeholder"]].map(function (field) { return h("label", { key: field[0], className: "dssm-field" }, h("span", { className: "dssm-label" }, t(field[1])), h("input", { className: "dssm-control", value: form[field[0]], placeholder: t(field[2]), onChange: function (e) { updateForm(field[0], e.target.value); } })); }), h("label", { className: "dssm-field" }, h("span", { className: "dssm-label" }, t("create.body")), h("textarea", { className: "dssm-control", value: form.body, placeholder: t("create.body.placeholder"), onChange: function (e) { updateForm("body", e.target.value); } })), h("p", { className: "dssm-help" }, t("create.chat.note"))), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setModal(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: busy || !form.name.trim() || !form.description.trim() || !form.body.trim(), onClick: submitCreate }, t("btn.create.now")))));
194
199
  if (modal === "import") content.push(h(Modal, { key: "import", className: "dssm-modal-import", title: t("import.title"), closeLabel: t("btn.close"), onClose: function () { pickerOpenRef.current = false; setUpload(null); setResult(null); setModal(null); } },
195
200
  h("input", { ref: importInputRef, className: "dssm-hidden-input", type: "file", accept: ".zip,.md", onChange: function (event) { selectUploadFiles(event.target.files); event.target.value = ""; } }),
196
201
  h("input", { ref: folderInputRef, className: "dssm-hidden-input", type: "file", multiple: true, webkitdirectory: "", directory: "", onChange: function (event) { selectUploadFiles(event.target.files); event.target.value = ""; } }),
@@ -204,7 +209,7 @@ window.__ModuleLoader__.load({
204
209
  h("div", { className: "dssm-upload-requirements" }, h("div", { className: "dssm-label" }, t("upload.requirements")), h("ul", null, h("li", null, t("upload.requirement.skill")), h("li", null, t("upload.requirement.frontmatter")), h("li", null, t("upload.requirement.copy")))),
205
210
  h("div", { className: "dssm-modal-actions" }, h("button", { type: "button", className: "dssm-btn dssm-btn-secondary", onClick: function () { pickerOpenRef.current = false; setUpload(null); setResult(null); setModal(null); } }, t("btn.cancel")), h("button", { type: "button", className: "dssm-btn", disabled: busy || !upload, onClick: submitImport }, busy ? t("upload.importing") : t("btn.import.now")))
206
211
  )); if (modal === "detail") content.push(h(Modal, { key: "detail", wide: true, title: t("detail.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, detail ? h(react.Fragment, null, h("div", { className: "dssm-detail-path" }, detail.path), h("div", { className: "dssm-detail-section" }, h("div", { className: "dssm-detail-title" }, t("detail.diagnostics")), detail.diagnostics.length ? detail.diagnostics.map(function (item, index) { return h("div", { key: index, className: "dssm-diag" }, t(item.code, item.params || {})); }) : h("div", { className: "dssm-note" }, t("detail.noIssues"))), h("div", { className: "dssm-detail-section" }, h("div", { className: "dssm-detail-title" }, t("detail.body")), h("pre", { className: "dssm-code" }, detail.body || "")), h("div", { className: "dssm-detail-section" }, h("div", { className: "dssm-detail-title" }, t("detail.frontmatter")), h("pre", { className: "dssm-code" }, JSON.stringify(detail.frontmatter, null, 2)))) : h("div", { className: "dssm-empty" }, t("loading"))));
207
- if (modal === "trash") content.push(h(Modal, { key: "trash-modal", title: t("trash.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dssm-count" }, t("trash.count", { count: data.trash.length })), data.trash.length ? data.trash.map(function (item) { return h("div", { key: item.id, className: "dssm-trash-item" }, h("div", { className: "dssm-trash-main" }, h("div", { className: "dssm-name" }, item.name), h("div", { className: "dssm-note" }, t("trash.deletedAt", { time: new Date(item.deletedAt).toLocaleString() }))), h("button", { className: "dssm-btn dssm-btn-quiet", disabled: busy, onClick: function () { post("/trash-restore", { id: item.id }, "result.restored", { name: item.name }); } }, t("btn.restore")), h("button", { className: "dssm-btn dssm-btn-quiet dssm-btn-danger", disabled: busy, onClick: function () { setModal({ type: "delete-confirm", id: item.id, name: item.name }); } }, t("btn.delete.forever"))); }) : h("div", { className: "dssm-empty" }, t("trash.empty"))));
212
+ if (modal === "trash") content.push(h(Modal, { key: "trash-modal", title: t("trash.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("div", { className: "dssm-count" }, t("trash.count", { count: data.trash.length })), data.trash.length ? data.trash.map(function (item) { return h("div", { key: item.id, className: "dssm-trash-item" }, h("div", { className: "dssm-trash-main" }, h("div", { className: "dssm-name" }, item.name), h("div", { className: "dssm-note" }, t("trash.deletedAt", { time: new Date(item.deletedAt).toLocaleString() }) + " · " + t("trash.source", { source: trashRootLabel(item) }))), h("button", { className: "dssm-btn dssm-btn-quiet", disabled: busy, onClick: function () { post("/trash-restore", { id: item.id }, "result.restored", { name: item.name }); } }, t("btn.restore")), h("button", { className: "dssm-btn dssm-btn-quiet dssm-btn-danger", disabled: busy, onClick: function () { setModal({ type: "delete-confirm", id: item.id, name: item.name }); } }, t("btn.delete.forever"))); }) : h("div", { className: "dssm-empty" }, t("trash.empty"))));
208
213
  if (modal && modal.type === "trash-confirm") content.push(h(Modal, { key: "trash-confirm", title: t("confirm.trash.title"), closeLabel: t("btn.close"), onClose: function () { setModal(null); } }, h("p", { className: "dssm-desc" }, t("confirm.trash.desc", { name: modal.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setModal(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: busy, onClick: function () { post("/delete", { root: modal.root, name: modal.name }, "result.trashed", { name: modal.name }).then(function () { setModal(null); }).catch(function () {}); } }, t("btn.trash")))));
209
214
  if (modal && modal.type === "delete-confirm") content.push(h(Modal, { key: "delete-confirm", title: t("confirm.delete.title"), closeLabel: t("btn.close"), onClose: function () { setModal("trash"); } }, h("p", { className: "dssm-desc" }, t("confirm.delete.desc", { name: modal.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setModal("trash"); } }, t("btn.cancel")), h("button", { className: "dssm-btn dssm-btn-danger", disabled: busy, onClick: function () { post("/trash-delete", { id: modal.id }, "result.deleted", { name: modal.name }).then(function () { setModal("trash"); }).catch(function () {}); } }, t("btn.delete.forever")))));
210
215
  return h("section", { className: "dssm-section" }, content);
@@ -212,7 +217,7 @@ window.__ModuleLoader__.load({
212
217
 
213
218
  var inject = ["slots", "locale"];
214
219
  function apply(ctx) { ctx.effect(function () { return ctx.locale.register(NS, DICT); }); ctx.slots.inject("settings.section", function () { return ctx.slots.register({ name: "settings.section", id: "skills-manager", order: 17, label: function () { return ctx.locale.bind(NS)("title"); }, icon: "skill", locale: NS }, SkillManagerSection); }); }
215
- module.exports.DICT = DICT; module.exports.translateError = translateError; module.exports.parseApiResponse = parseApiResponse; module.exports.isSkillEnabled = isSkillEnabled; module.exports.summarizeImportResult = summarizeImportResult; module.exports.normalizeSkillQuery = normalizeSkillQuery; module.exports.matchSkillQuery = matchSkillQuery; module.exports.filterSkills = filterSkills; module.exports.trapModalFocus = trapModalFocus; module.exports.handleModalEscape = handleModalEscape; module.exports.inspectUploadSelection = inspectUploadSelection; module.exports.apply = apply; module.exports.inject = inject;
220
+ module.exports.DICT = DICT; module.exports.translateError = translateError; module.exports.parseApiResponse = parseApiResponse; module.exports.isSkillEnabled = isSkillEnabled; module.exports.rootDisplayName = rootDisplayName; module.exports.summarizeImportResult = summarizeImportResult; module.exports.normalizeSkillQuery = normalizeSkillQuery; module.exports.matchSkillQuery = matchSkillQuery; module.exports.filterSkills = filterSkills; module.exports.visibleSkillRoots = visibleSkillRoots; module.exports.trapModalFocus = trapModalFocus; module.exports.handleModalEscape = handleModalEscape; module.exports.inspectUploadSelection = inspectUploadSelection; module.exports.apply = apply; module.exports.inject = inject;
216
221
  return module.exports;
217
222
  }
218
223
  });
package/lib/core.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // dsh-skills-manager core —— 纯 Node 技能文件管理核心(仅 ZIP 解压使用 fflate,可独立单测)
2
2
  //
3
- // 覆盖 DSH 用户级技能根:
4
- // - 根目录:~/.dsh/skills
3
+ // 覆盖 DSH 用户级与活动 Session 项目级技能根:
4
+ // - 用户根目录:~/.dsh/skills 与常见 Agent 用户目录
5
+ // - 项目根目录:<project>/.dsh/skills(可启停、创建、回收)、<project>/.agents/skills(只读展示)
5
6
  // - 条目形态:<root>/<name>/SKILL.md(bundle)或 <root>/<name>.md(flat),只扫一层
6
7
  // - 前端展示 name、description 与启停状态,不做格式检查或自动修复
7
8
  //
@@ -13,7 +14,7 @@
13
14
  import { homedir } from "node:os";
14
15
  import { join, basename, dirname, resolve, relative, isAbsolute, sep } from "node:path";
15
16
  import { promises as fs } from "node:fs";
16
- import { randomUUID } from "node:crypto";
17
+ import { createHash, randomUUID } from "node:crypto";
17
18
  import { unzipSync } from "fflate";
18
19
 
19
20
  const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -72,6 +73,85 @@ export function userRoots() {
72
73
  ];
73
74
  }
74
75
 
76
+ function projectIdentity(path) {
77
+ const canonical = process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
78
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
79
+ }
80
+
81
+ async function nearestProjectRoot(cwd) {
82
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) return null;
83
+ let start;
84
+ try {
85
+ start = await fs.realpath(resolve(cwd));
86
+ if (!(await fs.stat(start)).isDirectory()) return null;
87
+ } catch {
88
+ return { unavailable: true, cwd };
89
+ }
90
+ let current = start;
91
+ for (;;) {
92
+ try {
93
+ await fs.lstat(join(current, ".git"));
94
+ return { root: current, cwd: start };
95
+ } catch {
96
+ /* 继续向上查找最近的项目根 */
97
+ }
98
+ const parent = dirname(current);
99
+ if (parent === current) return null;
100
+ current = parent;
101
+ }
102
+ }
103
+
104
+ async function projectSourceSafe(definition) {
105
+ const container = join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents");
106
+ for (const path of [container, definition.path]) {
107
+ const st = await lstatOrNull(path);
108
+ if (st && (!st.isDirectory() || st.isSymbolicLink())) return false;
109
+ }
110
+ return !(await overlapsUserSkillRoot(definition.path));
111
+ }
112
+
113
+ /**
114
+ * 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
115
+ * 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
116
+ */
117
+ export async function projectRoots(projectCwds = [], diagnostics) {
118
+ const projects = new Map();
119
+ for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
120
+ const found = await nearestProjectRoot(cwd);
121
+ if (!found || !found.root) {
122
+ if (found && found.unavailable && Array.isArray(diagnostics) && typeof cwd === "string" && isAbsolute(cwd)) {
123
+ diagnostics.push({ code: "warning.project.unavailable", params: { path: cwd }, error: `无法从宿主读取活动工作区,项目技能未显示: ${cwd}` });
124
+ }
125
+ continue;
126
+ }
127
+ const identity = process.platform === "win32" ? found.root.toLowerCase() : found.root;
128
+ const existing = projects.get(identity) || { root: found.root, cwds: new Set() };
129
+ existing.cwds.add(found.cwd);
130
+ projects.set(identity, existing);
131
+ }
132
+ const roots = [];
133
+ for (const project of [...projects.values()].sort((a, b) => a.root.localeCompare(b.root))) {
134
+ const id = projectIdentity(project.root);
135
+ const common = {
136
+ mutable: false,
137
+ toggleable: false,
138
+ native: true,
139
+ scope: "project",
140
+ projectRoot: project.root,
141
+ projectName: basename(project.root) || project.root,
142
+ workspaceCwds: [...project.cwds].sort(),
143
+ };
144
+ const candidates = [
145
+ { ...common, key: `project-dsh:${id}`, kind: "project-dsh", localeKey: "projectDsh", path: join(project.root, ".dsh", "skills"), label: "Project DSH", rank: 100, mutable: true, toggleable: true },
146
+ { ...common, key: `project-agents:${id}`, kind: "project-agents", localeKey: "projectAgents", path: join(project.root, ".agents", "skills"), label: "Project Agent", rank: 200 },
147
+ ];
148
+ for (const candidate of candidates) {
149
+ if (await projectSourceSafe(candidate)) roots.push(candidate);
150
+ }
151
+ }
152
+ return roots;
153
+ }
154
+
75
155
  export function managerHomePath() {
76
156
  return join(resolveDshHome(), "skills-manager");
77
157
  }
@@ -149,13 +229,13 @@ function dshRootPath() {
149
229
  return userRoots().find((root) => root.key === "dsh").path;
150
230
  }
151
231
 
152
- /** 公共 Agent 目录的只读拒绝结果;action 为可翻译语义值(toggle/delete)。 */
232
+ /** 只读来源的拒绝结果;action 为可翻译语义值(toggle/delete)。 */
153
233
  function readonlyError(action) {
154
234
  return {
155
235
  ok: false,
156
236
  code: "error.root.readonly",
157
237
  params: { action },
158
- error: action === "delete" ? "公共 Agent 技能目录不允许删除" : "公共 Agent 技能目录不允许启用或停用",
238
+ error: action === "delete" ? "该技能来源不允许删除" : "该技能来源不允许启用或停用",
159
239
  };
160
240
  }
161
241
 
@@ -170,6 +250,38 @@ function rootByKey(key) {
170
250
  return userRoots().find((item) => item.key === key) || null;
171
251
  }
172
252
 
253
+ /** 只允许用户 DSH 根,或由活动 Session 推导出的项目 DSH 根参与文件写入。 */
254
+ function writableRootDefinition(root) {
255
+ const definition = rootDefinition(root);
256
+ if (!definition || definition.mutable !== true) return null;
257
+ if (definition.key === "dsh") return resolve(definition.path) === resolve(dshRootPath()) ? rootByKey("dsh") : null;
258
+ if (
259
+ definition.scope !== "project"
260
+ || definition.kind !== "project-dsh"
261
+ || typeof definition.projectRoot !== "string"
262
+ || !isAbsolute(definition.projectRoot)
263
+ || definition.key !== `project-dsh:${projectIdentity(definition.projectRoot)}`
264
+ || resolve(definition.path) !== resolve(join(definition.projectRoot, ".dsh", "skills"))
265
+ ) return null;
266
+ return definition;
267
+ }
268
+
269
+ async function checkedWritableRootDefinition(root) {
270
+ const definition = writableRootDefinition(root);
271
+ if (!definition || definition.scope !== "project") return definition;
272
+ if (await overlapsUserSkillRoot(definition.path)) {
273
+ return { ok: false, error: `项目技能目录与用户技能目录重叠,拒绝写入: ${definition.path}`, code: "error.root.unsafe", params: { path: definition.path } };
274
+ }
275
+ // 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
276
+ for (const path of [join(definition.projectRoot, ".dsh"), definition.path]) {
277
+ const st = await lstatOrNull(path);
278
+ if (st && (!st.isDirectory() || st.isSymbolicLink())) {
279
+ return { ok: false, error: `项目技能目录不安全,拒绝写入: ${path}`, code: "error.root.unsafe", params: { path } };
280
+ }
281
+ }
282
+ return definition;
283
+ }
284
+
173
285
  /** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
174
286
  function isSameOrDescendant(parent, child) {
175
287
  const rel = relative(resolve(parent), resolve(child));
@@ -185,6 +297,31 @@ async function resolvedPath(path) {
185
297
  }
186
298
  }
187
299
 
300
+ /** 即使末级路径尚不存在,也解析最近既存祖先中的链接,供权限域重叠判断。 */
301
+ async function comparisonPath(path) {
302
+ let current = resolve(path);
303
+ const missing = [];
304
+ for (;;) {
305
+ try {
306
+ return resolve(await fs.realpath(current), ...missing);
307
+ } catch {
308
+ const parent = dirname(current);
309
+ if (parent === current) return resolve(path);
310
+ missing.unshift(basename(current));
311
+ current = parent;
312
+ }
313
+ }
314
+ }
315
+
316
+ async function overlapsUserSkillRoot(path) {
317
+ const candidate = await comparisonPath(path);
318
+ for (const root of userRoots()) {
319
+ const userPath = await comparisonPath(root.path);
320
+ if (isSameOrDescendant(candidate, userPath) || isSameOrDescendant(userPath, candidate)) return true;
321
+ }
322
+ return false;
323
+ }
324
+
188
325
  /** 两个路径重叠时,覆盖导入可能删除自身来源,必须拒绝。 */
189
326
  async function pathsOverlap(a, b) {
190
327
  const left = await resolvedPath(a);
@@ -336,6 +473,47 @@ export async function renameWithRetry(source, destination, options = {}) {
336
473
  }
337
474
  }
338
475
 
476
+ async function removeMovedPath(path) {
477
+ const st = await lstatOrNull(path);
478
+ if (!st) return;
479
+ if (st.isDirectory() && !st.isSymbolicLink()) await fs.rm(path, { recursive: true, force: false });
480
+ else await fs.unlink(path);
481
+ }
482
+
483
+ /** rename 跨盘返回 EXDEV 时:先完整复制,再在源盘原子隐藏源条目,最后清理隐藏副本。 */
484
+ async function movePathWithFallback(source, destination, options = {}) {
485
+ try {
486
+ await renameWithRetry(source, destination, options);
487
+ return { copied: false, cleanupError: null };
488
+ } catch (error) {
489
+ if (!error || error.code !== "EXDEV") throw error;
490
+ }
491
+
492
+ const quarantine = join(dirname(source), `.${basename(source)}.dssm-move-${randomUUID()}`);
493
+ try {
494
+ await fs.cp(source, destination, {
495
+ recursive: true,
496
+ dereference: false,
497
+ errorOnExist: true,
498
+ force: false,
499
+ verbatimSymlinks: true,
500
+ });
501
+ // 源与 quarantine 位于同一目录;成功后原技能名立即消失,避免递归删除留下半份可见条目。
502
+ await renameWithRetry(source, quarantine, options);
503
+ } catch (error) {
504
+ await removeMovedPath(destination).catch(() => undefined);
505
+ throw error;
506
+ }
507
+
508
+ let cleanupError = null;
509
+ try {
510
+ await removeMovedPath(quarantine);
511
+ } catch (error) {
512
+ cleanupError = error;
513
+ }
514
+ return { copied: true, cleanupError, quarantine };
515
+ }
516
+
339
517
  async function writeFileAtomically(path, content) {
340
518
  const temp = join(dirname(path), `.${basename(path)}.dssm-${randomUUID()}.tmp`);
341
519
  try {
@@ -362,6 +540,8 @@ export async function resolveEntry(root, name) {
362
540
  const bundlePath = entryPath(root, name);
363
541
  if (bundlePath === null) return null;
364
542
  const rootPath = resolve(root);
543
+ const rootStat = await lstatOrNull(rootPath);
544
+ if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink()) return null;
365
545
  const rootReal = await resolvedPath(rootPath);
366
546
  const bundleStat = await lstatOrNull(bundlePath);
367
547
  if (bundleStat && bundleStat.isDirectory() && !bundleStat.isSymbolicLink()) {
@@ -411,6 +591,8 @@ function entryOf(name, kind, docPath, doc) {
411
591
 
412
592
  /** 扫描一个技能根(只扫一层)。返回 { exists, entries }。 */
413
593
  export async function scanEntries(root) {
594
+ const rootStat = await lstatOrNull(resolve(root));
595
+ if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink()) return { exists: false, entries: [] };
414
596
  let items;
415
597
  try {
416
598
  items = await fs.readdir(root, { withFileTypes: true });
@@ -552,9 +734,12 @@ async function setReadOnlySkillEnabled(root, name, enabled, log) {
552
734
 
553
735
  /** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
554
736
  export async function setSkillEnabled(root, name, enabled, log) {
555
- const definition = rootDefinition(root);
737
+ let definition = rootDefinition(root);
738
+ if (!definition || !definition.toggleable) return readonlyError("toggle");
739
+ if (definition.key !== "dsh" && definition.kind !== "project-dsh") return setReadOnlySkillEnabled(definition, name, enabled, log);
740
+ definition = await checkedWritableRootDefinition(definition);
741
+ if (definition && definition.ok === false) return definition;
556
742
  if (!definition) return readonlyError("toggle");
557
- if (definition.key !== "dsh") return setReadOnlySkillEnabled(definition, name, enabled, log);
558
743
  const resolved = await resolveEntry(definition.path, name);
559
744
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
560
745
  const source = await fs.readFile(resolved.docPath, "utf8");
@@ -562,7 +747,7 @@ export async function setSkillEnabled(root, name, enabled, log) {
562
747
  if (updated === null) return { ok: false, error: `技能缺少完整 frontmatter,无法${enabled ? "启用" : "停用"}: ${name}`, code: "error.skill.noFrontmatter", params: { name, action: enabled ? "enable" : "disable" } };
563
748
  await writeFileAtomically(resolved.docPath, updated);
564
749
  if (log) log(enabled ? "enable" : "disable", `${enabled ? "启用" : "停用"} ${resolved.docPath}`);
565
- return { name, enabled };
750
+ return { root: definition.key, name, enabled };
566
751
  }
567
752
 
568
753
  async function safeExistingEntryPaths(root, name) {
@@ -626,10 +811,48 @@ async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
626
811
  return { fallback: true, cleanupError };
627
812
  }
628
813
 
629
- /** 把 DSH 根目录中的单个技能移入 manager-owned 回收站。 */
814
+ function trashRootMetadata(definition) {
815
+ if (definition.key === "dsh") return { key: "dsh", scope: "user", label: definition.label };
816
+ return {
817
+ key: definition.key,
818
+ scope: "project",
819
+ kind: "project-dsh",
820
+ projectRoot: definition.projectRoot,
821
+ projectName: definition.projectName,
822
+ label: definition.label,
823
+ };
824
+ }
825
+
826
+ async function restoreRootDefinition(metadata, options = {}) {
827
+ // version 1 entries predate scoped Trash and always belong to $DSH_HOME/skills.
828
+ if (!metadata.root) return rootByKey("dsh");
829
+ if (metadata.root.scope === "user" && metadata.root.key === "dsh") return rootByKey("dsh");
830
+ if (
831
+ metadata.root.scope !== "project"
832
+ || metadata.root.kind !== "project-dsh"
833
+ || typeof metadata.root.key !== "string"
834
+ || typeof metadata.root.projectRoot !== "string"
835
+ || !isAbsolute(metadata.root.projectRoot)
836
+ ) return { ok: false, error: `回收站条目来源非法: ${metadata.id}`, code: "error.trash.invalid", params: { id: metadata.id } };
837
+ const roots = await projectRoots(options.projectCwds);
838
+ const normalizedProjectRoot = process.platform === "win32" ? resolve(metadata.root.projectRoot).toLowerCase() : resolve(metadata.root.projectRoot);
839
+ const root = roots.find((item) => item.key === metadata.root.key && item.kind === "project-dsh" && (process.platform === "win32" ? resolve(item.projectRoot).toLowerCase() : resolve(item.projectRoot)) === normalizedProjectRoot);
840
+ if (!root) {
841
+ return {
842
+ ok: false,
843
+ error: `原项目当前不在活动工作区中,无法恢复: ${metadata.root.projectRoot}`,
844
+ code: "error.trash.projectUnavailable",
845
+ params: { path: metadata.root.projectRoot },
846
+ };
847
+ }
848
+ return checkedWritableRootDefinition(root);
849
+ }
850
+
851
+ /** 把用户或活动项目的 DSH 根中的单个技能移入 manager-owned 回收站。 */
630
852
  export async function deleteSkill(root, name, log, options = {}) {
631
- const definition = rootDefinition(root);
632
- if (!definition || definition.key !== "dsh") return readonlyError("delete");
853
+ const definition = await checkedWritableRootDefinition(root);
854
+ if (definition && definition.ok === false) return definition;
855
+ if (!definition) return readonlyError("delete");
633
856
  const resolved = await resolveEntry(definition.path, name);
634
857
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
635
858
  const targets = await safeExistingEntryPaths(definition.path, name);
@@ -642,20 +865,21 @@ export async function deleteSkill(root, name, log, options = {}) {
642
865
  try {
643
866
  for (const target of targets) {
644
867
  const destination = join(stage, target.fileName);
645
- await renameWithRetry(target.path, destination, options.renameOptions);
868
+ const transferred = await movePathWithFallback(target.path, destination, options.renameOptions);
869
+ if (transferred.cleanupError && log) log("trash-source-warning", `技能已跨盘移入回收站,但源盘隐藏副本等待后续清理: ${transferred.quarantine}(${transferred.cleanupError.message || transferred.cleanupError})`);
646
870
  moved.push({ ...target, destination });
647
871
  }
648
- const metadata = { version: 1, id, name, deletedAt: new Date().toISOString(), entries: moved.map((item) => item.fileName) };
872
+ const metadata = { version: 2, id, name, deletedAt: new Date().toISOString(), entries: moved.map((item) => item.fileName), root: trashRootMetadata(definition) };
649
873
  await fs.writeFile(join(stage, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
650
874
  const published = await publishTrashStage(stage, finalPath, metadata, options.renameOptions);
651
875
  if (published.cleanupError && log) log("trash-stage-warning", `回收站已发布,但临时目录等待后续清理: ${stage}(${published.cleanupError.message || published.cleanupError})`);
652
876
  if (log) log("trash", `移到回收站 ${name} -> ${finalPath}`);
653
- return { id, name, deletedAt: metadata.deletedAt };
877
+ return { id, name, deletedAt: metadata.deletedAt, root: metadata.root };
654
878
  } catch (error) {
655
879
  const rollbackFailures = [];
656
880
  for (const item of moved.reverse()) {
657
881
  try {
658
- await renameWithRetry(item.destination, item.path, options.renameOptions);
882
+ await movePathWithFallback(item.destination, item.path, options.renameOptions);
659
883
  } catch (rollbackError) {
660
884
  rollbackFailures.push({ path: item.destination, error: String(rollbackError && rollbackError.message ? rollbackError.message : rollbackError) });
661
885
  }
@@ -689,10 +913,12 @@ export async function listTrash() {
689
913
  return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
690
914
  }
691
915
 
692
- export async function restoreTrash(id, log) {
916
+ export async function restoreTrash(id, log, options = {}) {
693
917
  const metadata = await readTrashMetadata(id);
694
918
  if (!metadata) return { ok: false, error: `回收站条目不存在: ${id}`, code: "error.trash.notFound", params: { id } };
695
- const root = dshRootPath();
919
+ const definition = await restoreRootDefinition(metadata, options);
920
+ if (!definition || definition.ok === false) return definition || readonlyError("restore");
921
+ const root = definition.path;
696
922
  const conflicts = await safeExistingEntryPaths(root, metadata.name);
697
923
  if (conflicts.length) return { ok: false, error: `无法恢复,同名技能已存在: ${metadata.name}`, code: "error.trash.conflict", params: { name: metadata.name } };
698
924
  await fs.mkdir(root, { recursive: true });
@@ -703,14 +929,14 @@ export async function restoreTrash(id, log) {
703
929
  const source = join(itemRoot, fileName);
704
930
  const destination = join(root, fileName);
705
931
  if (!isSameOrDescendant(itemRoot, source) || !isSameOrDescendant(root, destination)) throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
706
- await renameWithRetry(source, destination);
932
+ await movePathWithFallback(source, destination, options.renameOptions);
707
933
  moved.push({ source, destination });
708
934
  }
709
935
  await fs.rm(itemRoot, { recursive: true, force: true });
710
- if (log) log("restore", `从回收站恢复 ${metadata.name}`);
711
- return { id, name: metadata.name };
936
+ if (log) log("restore", `从回收站恢复 ${metadata.name} -> ${root}`);
937
+ return { id, name: metadata.name, root: trashRootMetadata(definition) };
712
938
  } catch (error) {
713
- for (const item of moved.reverse()) await renameWithRetry(item.destination, item.source).catch(() => undefined);
939
+ for (const item of moved.reverse()) await movePathWithFallback(item.destination, item.source, options.renameOptions).catch(() => undefined);
714
940
  throw error;
715
941
  }
716
942
  }
@@ -1128,18 +1354,23 @@ function yamlString(value) {
1128
1354
  return JSON.stringify(String(value));
1129
1355
  }
1130
1356
 
1131
- export async function createSkill(input, log) {
1357
+ export async function createSkill(input, log, options = {}) {
1358
+ const requestedRoot = Object.prototype.hasOwnProperty.call(options, "root") ? options.root : rootByKey("dsh");
1359
+ const definition = await checkedWritableRootDefinition(requestedRoot);
1360
+ if (definition && definition.ok === false) return definition;
1361
+ if (!definition) return readonlyError("create");
1362
+ const root = definition.path;
1132
1363
  const requestedName = String(input && input.name || "").trim();
1133
1364
  const name = toKebab(requestedName);
1134
1365
  const description = String(input && input.description || "").trim();
1135
1366
  const body = String(input && input.body || "").trim();
1136
- if (!name || !KEBAB_RE.test(name) || entryPath(dshRootPath(), name) === null) return { ok: false, error: `无法生成合法 kebab-case 名称(原始名: ${requestedName})`, code: "error.import.invalidName", params: { name: requestedName } };
1367
+ if (!name || !KEBAB_RE.test(name) || entryPath(root, name) === null) return { ok: false, error: `无法生成合法 kebab-case 名称(原始名: ${requestedName})`, code: "error.import.invalidName", params: { name: requestedName } };
1137
1368
  if (!description) return { ok: false, error: "技能简介不能为空", code: "error.create.descriptionRequired" };
1138
1369
  if (!body) return { ok: false, error: "技能正文不能为空", code: "error.create.bodyRequired" };
1139
1370
  if (description.length > 500 || body.length > (1 << 18)) return { ok: false, error: "技能内容过长", code: "error.create.tooLarge" };
1140
- if (await safeExistingEntryPaths(dshRootPath(), name).then((items) => items.length > 0)) return { ok: false, error: `同名技能已存在: ${name}`, code: "error.create.conflict", params: { name } };
1141
- await fs.mkdir(dshRootPath(), { recursive: true });
1142
- const target = entryPath(dshRootPath(), name);
1371
+ if (await safeExistingEntryPaths(root, name).then((items) => items.length > 0)) return { ok: false, error: `同名技能已存在: ${name}`, code: "error.create.conflict", params: { name } };
1372
+ await fs.mkdir(root, { recursive: true });
1373
+ const target = entryPath(root, name);
1143
1374
  const stage = temporaryPath(target, "create");
1144
1375
  try {
1145
1376
  await fs.mkdir(stage);
@@ -1151,11 +1382,12 @@ export async function createSkill(input, log) {
1151
1382
  throw error;
1152
1383
  }
1153
1384
  if (log) log("create", `创建 ${join(target, "SKILL.md")}`);
1154
- return { name, path: join(target, "SKILL.md") };
1385
+ return { name, path: join(target, "SKILL.md"), root: definition.key };
1155
1386
  }
1156
1387
 
1157
- export async function skillDetail(key, name) {
1158
- const root = rootByKey(key);
1388
+ export async function skillDetail(key, name, options = {}) {
1389
+ const scopedRoots = await projectRoots(options.projectCwds);
1390
+ const root = rootByKey(key) || scopedRoots.find((item) => item.key === key);
1159
1391
  if (!root) return { ok: false, error: `未知技能来源: ${key}`, code: "error.root.unknown", params: { root: key } };
1160
1392
  const entry = await resolveEntry(root.path, name);
1161
1393
  if (!entry) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
@@ -1229,18 +1461,46 @@ export async function getProviderSkill(candidate) {
1229
1461
 
1230
1462
  // ── 状态快照 ────────────────────────────────────────────────────────────────
1231
1463
 
1232
- /** DSH 与常见 Agent 根目录技能快照。 */
1233
- export async function state() {
1234
- const roots = userRoots();
1464
+ function canonicalSkillName(item) {
1465
+ return item.entry.declaredName || item.entry.name;
1466
+ }
1467
+
1468
+ function markWinners(items, options = {}) {
1469
+ const winners = new Map();
1470
+ for (const item of [...items].sort((a, b) => a.root.rank - b.root.rank)) {
1471
+ const canonicalName = canonicalSkillName(item);
1472
+ if (!item.entry.loadable) continue;
1473
+ if (!winners.has(canonicalName)) {
1474
+ winners.set(canonicalName, item);
1475
+ } else if (options.markShadowed !== false) {
1476
+ item.view.shadowedBy = { root: winners.get(canonicalName).root.key, name: winners.get(canonicalName).entry.name };
1477
+ }
1478
+ }
1479
+ for (const [canonicalName, winner] of winners) {
1480
+ if (options.markWinner !== false) winner.view.winner = true;
1481
+ if (options.setLoaded === false) winner.view.discovered = true;
1482
+ else winner.view.loaded = winner.managerEnabled && winner.entry.modelInvocable && winner.entry.userInvocable;
1483
+ winner.view.canonicalName = canonicalName;
1484
+ }
1485
+ return winners;
1486
+ }
1487
+
1488
+ /** DSH、常见 Agent 与活动 Session 项目根的技能快照。 */
1489
+ export async function state(options = {}) {
1490
+ const user = userRoots();
1491
+ const projectWarnings = [];
1492
+ const scoped = await projectRoots(options.projectCwds, projectWarnings);
1235
1493
  const policyResult = await readManagerState();
1236
1494
  const trash = await listTrash();
1237
- const result = { roots: [], trash, warnings: policyResult.warning ? [policyResult.warning] : [] };
1495
+ const result = { roots: [], projects: [], trash, warnings: [...(policyResult.warning ? [policyResult.warning] : []), ...projectWarnings] };
1238
1496
  const all = [];
1239
- for (const root of roots) {
1497
+ for (const root of [...scoped, ...user]) {
1240
1498
  const { exists, entries } = await scanEntries(root.path);
1499
+ // 即使项目 .dsh/skills 尚不存在,也要把可写根返回给创建表单;只读项目根仍按实际存在性展示。
1500
+ if (root.scope === "project" && !exists && root.kind !== "project-dsh") continue;
1241
1501
  const skills = [];
1242
1502
  for (const e of entries) {
1243
- const managerEnabled = root.key === "dsh" ? true : managerSkillEnabled(policyResult.state, root.key, e.name);
1503
+ const managerEnabled = root.scope === "project" || root.key === "dsh" ? true : managerSkillEnabled(policyResult.state, root.key, e.name);
1244
1504
  skills.push({
1245
1505
  name: e.name,
1246
1506
  declaredName: e.declaredName,
@@ -1257,21 +1517,41 @@ export async function state() {
1257
1517
  });
1258
1518
  all.push({ root, entry: e, managerEnabled, view: skills[skills.length - 1] });
1259
1519
  }
1260
- result.roots.push({ key: root.key, path: root.path, label: root.label, mutable: root.mutable, toggleable: root.toggleable, native: root.native, exists, enabled: root.key === "dsh" ? true : policyResult.state.sources[root.key] !== false, skills });
1520
+ result.roots.push({
1521
+ key: root.key,
1522
+ ...(root.kind ? { kind: root.kind } : {}),
1523
+ ...(root.localeKey ? { localeKey: root.localeKey } : {}),
1524
+ path: root.path,
1525
+ label: root.label,
1526
+ mutable: root.mutable,
1527
+ toggleable: root.toggleable,
1528
+ native: root.native,
1529
+ rank: root.rank,
1530
+ scope: root.scope || "user",
1531
+ ...(root.projectRoot ? { projectRoot: root.projectRoot, projectName: root.projectName, workspaceCwds: root.workspaceCwds } : {}),
1532
+ exists,
1533
+ enabled: root.scope === "project" || root.key === "dsh" ? true : policyResult.state.sources[root.key] !== false,
1534
+ skills,
1535
+ });
1261
1536
  }
1262
- const winners = new Map();
1263
- for (const item of all.sort((a, b) => a.root.rank - b.root.rank)) {
1264
- const canonicalName = item.entry.declaredName || item.entry.name;
1265
- if (!item.entry.loadable) continue;
1266
- if (!winners.has(canonicalName)) winners.set(canonicalName, item);
1267
- else item.view.shadowedBy = { root: winners.get(canonicalName).root.key, name: winners.get(canonicalName).entry.name };
1537
+ const userItems = all.filter((item) => item.root.scope !== "project");
1538
+ markWinners(userItems);
1539
+ const projectGroups = new Map();
1540
+ for (const item of all.filter((candidate) => candidate.root.scope === "project")) {
1541
+ const group = projectGroups.get(item.root.projectRoot) || [];
1542
+ group.push(item);
1543
+ projectGroups.set(item.root.projectRoot, group);
1268
1544
  }
1269
- for (const [canonicalName, winner] of winners) {
1270
- winner.view.winner = true;
1271
- winner.view.loaded = winner.root.key === "dsh"
1272
- ? winner.entry.modelInvocable && winner.entry.userInvocable
1273
- : winner.managerEnabled && winner.entry.modelInvocable && winner.entry.userInvocable;
1274
- winner.view.canonicalName = canonicalName;
1545
+ for (const projectItems of projectGroups.values()) {
1546
+ // 用户级条目参与该 workspace 的优先级判定,但不把其全局视图标记为“被某项目覆盖”。
1547
+ const userCopies = userItems.map((item) => ({ ...item, view: { ...item.view } }));
1548
+ markWinners([...projectItems, ...userCopies], { setLoaded: false });
1549
+ }
1550
+ const seenProjects = new Set();
1551
+ for (const root of result.roots.filter((item) => item.scope === "project")) {
1552
+ if (seenProjects.has(root.projectRoot)) continue;
1553
+ seenProjects.add(root.projectRoot);
1554
+ result.projects.push({ root: root.projectRoot, name: root.projectName, workspaceCwds: root.workspaceCwds });
1275
1555
  }
1276
1556
  result.summary = {
1277
1557
  total: all.length,
package/lib/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // - host 路由仅依赖 node: 内置与本地 core.js;ZIP 解压封装在 core
3
3
  // - 路由:状态、启停、详情、创建、导入、回收站
4
4
  // - 全部接口先校验 DSH Web 信任的 Host;写接口再校验请求标记与 JSON
5
- // - $DSH_HOME\skills 可创建、导入、回收、启停;外部 Agent 根只读,由 manager 状态控制加载
5
+ // - $DSH_HOME\skills 可创建、导入、回收、启停;用户级外部 Agent 根只读,由 manager 状态控制加载
6
+ // - 活动 Session 的项目 .dsh/skills 可启停、创建、回收与恢复;项目 .agents/skills 保持只读
6
7
 
7
8
  import { appendFile, rename, rm, stat } from "node:fs/promises";
8
9
  import {
@@ -20,11 +21,12 @@ import {
20
21
  listProviderCandidates,
21
22
  getProviderSkill,
22
23
  userRoots,
24
+ projectRoots,
23
25
  logPath,
24
26
  } from "./core.js";
25
27
 
26
28
  const name = "skills-manager";
27
- const inject = ["webServer", "webRuntime", "skills", "tools"];
29
+ const inject = ["webServer", "webRuntime", "skills", "tools", "sessions"];
28
30
  const CLIENT_MARKER_HEADER = "x-dsh-skills-manager";
29
31
  const MAX_LOG_BYTES = 1 << 20;
30
32
  // 文件夹上传允许 64 MiB 原始内容;Base64 会膨胀约 1/3,再为最多 1000 条路径预留余量。
@@ -243,6 +245,20 @@ function notifyChatCatalog(ctx, invalidateSkills) {
243
245
  }
244
246
  }
245
247
 
248
+ /** 活动 Session 的宿主 cwd;项目 Skill 只从这些已知 workspace 推导。 */
249
+ function activeSessionCwds(ctx) {
250
+ try {
251
+ const sessions = ctx.sessions || (typeof ctx.get === "function" ? ctx.get("sessions") : undefined);
252
+ const list = sessions && typeof sessions.list === "function" ? sessions.list() : [];
253
+ return [...new Set(list
254
+ .map((session) => session && session.header && session.header.cwd)
255
+ .filter((cwd) => typeof cwd === "string" && cwd.trim() !== "")
256
+ .map((cwd) => cwd.trim()))];
257
+ } catch {
258
+ return [];
259
+ }
260
+ }
261
+
246
262
  function externalSkillProvider(control, invalidators) {
247
263
  invalidators.add(control.invalidate);
248
264
  if (control.signal && typeof control.signal.addEventListener === "function") {
@@ -306,6 +322,8 @@ function apply(ctx) {
306
322
  const trustedHosts = Array.isArray(ctx.webRuntime.trustedHosts) ? ctx.webRuntime.trustedHosts : [];
307
323
  const roots = userRoots();
308
324
  const rootByKey = Object.fromEntries(roots.map((r) => [r.key, r]));
325
+ const projectOptions = () => ({ projectCwds: activeSessionCwds(ctx) });
326
+ const requestRoot = async (key) => rootByKey[key] || (await projectRoots(projectOptions().projectCwds)).find((root) => root.key === key);
309
327
  const providerInvalidators = new Set();
310
328
  const invalidateSkills = () => {
311
329
  for (const invalidate of providerInvalidators) invalidate();
@@ -368,11 +386,11 @@ function apply(ctx) {
368
386
  return;
369
387
  }
370
388
  if (req.method === "GET" && path === "/api/dsh-skills-manager/state") {
371
- return run(res, state);
389
+ return run(res, () => state(projectOptions()));
372
390
  }
373
391
  if (req.method === "HEAD") {
374
392
  // HEAD 复用 GET/405 的载荷计算 content-length,保持与实体一致的响应头语义。
375
- if (path === "/api/dsh-skills-manager/state") return run(res, state, undefined, true);
393
+ if (path === "/api/dsh-skills-manager/state") return run(res, () => state(projectOptions()), undefined, true);
376
394
  json(res, 405, { ok: false, code: "error.proto.method", error: `method not allowed: ${req.method}` }, true);
377
395
  return;
378
396
  }
@@ -392,23 +410,27 @@ function apply(ctx) {
392
410
  return enqueueMutation(() => {
393
411
  switch (path) {
394
412
  case "/api/dsh-skills-manager/enable":
395
- return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), true, log), afterWrite);
413
+ return run(res, async () => setSkillEnabled(await requestRoot(String(body.root || "dsh")), String(body.name || ""), true, log), afterWrite);
396
414
  case "/api/dsh-skills-manager/disable":
397
- return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), false, log), afterWrite);
415
+ return run(res, async () => setSkillEnabled(await requestRoot(String(body.root || "dsh")), String(body.name || ""), false, log), afterWrite);
398
416
  case "/api/dsh-skills-manager/source-enable":
399
417
  return run(res, () => setSourceEnabled(String(body.root || ""), true, log), afterWrite);
400
418
  case "/api/dsh-skills-manager/source-disable":
401
419
  return run(res, () => setSourceEnabled(String(body.root || ""), false, log), afterWrite);
402
420
  case "/api/dsh-skills-manager/delete":
403
- return run(res, () => deleteSkill(rootByKey[String(body.root || "dsh")], String(body.name || ""), log), afterWrite);
421
+ return run(res, async () => deleteSkill(await requestRoot(String(body.root || "dsh")), String(body.name || ""), log), afterWrite);
404
422
  case "/api/dsh-skills-manager/trash-restore":
405
- return run(res, () => restoreTrash(String(body.id || ""), log), afterWrite);
423
+ return run(res, () => restoreTrash(String(body.id || ""), log, projectOptions()), afterWrite);
406
424
  case "/api/dsh-skills-manager/trash-delete":
407
425
  return run(res, () => permanentlyDeleteTrash(String(body.id || ""), log));
408
426
  case "/api/dsh-skills-manager/detail":
409
- return run(res, () => skillDetail(String(body.root || "dsh"), String(body.name || "")));
427
+ return run(res, () => skillDetail(String(body.root || "dsh"), String(body.name || ""), projectOptions()));
410
428
  case "/api/dsh-skills-manager/create":
411
- return run(res, () => createSkill({ name: body.name, description: body.description, body: body.body }, log), afterWrite);
429
+ return run(res, async () => createSkill(
430
+ { name: body.name, description: body.description, body: body.body },
431
+ log,
432
+ { root: await requestRoot(String(body.root || "dsh")) },
433
+ ), afterWrite);
412
434
  case "/api/dsh-skills-manager/import":
413
435
  return run(res, () => importSkill(String(body.source || ""), log, {
414
436
  conflict: body.conflict === "overwrite" ? "overwrite" : "skip",
@@ -434,4 +456,4 @@ function apply(ctx) {
434
456
  return route;
435
457
  }
436
458
 
437
- export { apply, inject, name, notifyChatCatalog, registerAgentSkillProviders };
459
+ export { activeSessionCwds, apply, inject, name, notifyChatCatalog, registerAgentSkillProviders };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
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": {