@morehao/dsh-skills-console 0.2.4 → 0.3.0

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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入其他 Agent、随时启停,外加一份订阅清单。
10
10
 
11
- 它不搬技能。技能真身始终由 [`skills`](https://www.npmjs.com/package/skills) CLI 写进 `~/.agents/skills`;本插件只在 DSH 这一侧做控制台:决定**哪些技能、对谁、在哪一层可见**,并把每次改动做成可回滚的事务。
11
+ 它不搬技能。技能真身由插件内的 **native installer** 写进 `~/.agents/skills`,并维护 v3 lock;本插件只在 DSH 这一侧做控制台:决定**哪些技能、对谁、在哪一层可见**,并把每次改动做成可回滚的事务。安装、更新、删除都由 native installer 完成,不依赖 `skills` CLI 或系统 git。
12
12
 
13
13
  ## 为什么需要它
14
14
 
@@ -22,10 +22,10 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
22
22
 
23
23
  | 能力 | 说明 |
24
24
  | --- | --- |
25
- | 清单 | canonical 扫描 + CLI lock 对账 + DSH 实际可见性对账;canonical 里已有的技能首次读取自动纳入追踪,不必逐个「接管」 |
26
- | 安装 | 填 `owner/repo`(可只装其中几个技能),下载、落盘、写 lock 全部交给 CLI;来自订阅快照时核对 lock 哈希,不一致会提示 |
25
+ | 清单 | canonical 扫描 + v3 lock 对账 + DSH 实际可见性对账;canonical 里已有的技能首次读取自动纳入追踪,不必逐个「接管」 |
26
+ | 安装 | 填 `owner/repo`(可只装其中几个技能),native installer 下载 codeload tarball → 安全解压 → 本地计算 tree SHA → 原子落盘 canonical + v3 lock;来自订阅快照时核对哈希,不一致会提示 |
27
27
  | 更新 | GitHub Trees 检测上游新 commit → 计划(commit 区间 + 影响范围 + 防抖 token)→ 应用前备份,失败自动回滚上一版 |
28
- | 删除 | 先体检(真实目录 / 外部链接直接中止)→ 快照进回收站 → 只删本插件建的链接与记账,CLI 的 `remove` 不带 `-a` |
28
+ | 删除 | 先体检(真实目录 / 外部链接直接中止)→ 快照进回收站 → installer 删 lock 条目与真身(lock 写失败从回收站恢复)→ 只清本插件建的链接与记账 |
29
29
  | 接入 | 软链四态判定(`managed` / `managed-external` / `foreign-link` / `real`),**绝不覆盖**别人创建的东西;只列本机已安装目录,已删的 Agent 自动从列表消失 |
30
30
  | 启停 | `mode: on/off` 只摘或重建 DSH 侧链接,不动 `SKILL.md`,其他 Agent 的链接也不受影响 |
31
31
  | 作用范围 | 全局 `~/.dsh/skills`,或仅指定项目;项目可直接勾选 DSH 已登记的工作区,也可手动选目录 |
@@ -41,7 +41,7 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
41
41
 
42
42
  ## 安装
43
43
 
44
- 插件依赖里自带 `skills@1.7.4`,**不需要用户预装 Node / npm / npx**:CLI 由宿主自带的 Node 启动,装插件时随依赖一起落盘。安装技能仍需要**本机有 git**(CLI 会 `git clone` 仓库);首次装插件需要联网拉取依赖。
44
+ 插件只带 `tar@7.5.22`(native installer 解压),**不需要用户预装 Node / npm / npx / git**。首次装插件需要联网拉取依赖。
45
45
 
46
46
  在 Harness 里装本地 link:
47
47
 
@@ -57,7 +57,7 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
57
57
  name: '@morehao/dsh-skills-console'
58
58
  ```
59
59
 
60
- 客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `skills`),否则 CLI 依赖层不会命中。
60
+ 客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。本地 `link:` 开发时,先在仓库跑一次 `npm install`(装上 `tar`),否则 native installer 的解压依赖层不会命中。
61
61
 
62
62
  ## 快速上手
63
63
 
@@ -68,9 +68,9 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
68
68
 
69
69
  ## 可靠性
70
70
 
71
- - **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`)的增删改只经 `skills` CLI,插件不直接写;探测 / 更新检查会把仓库临时 clone 到 `os.tmpdir()`(只读、用完即删),结果绝不落进 canonical 或 lock。
71
+ - **canonical 与 v3 lock 的写入口**:安装、更新、删除由 native installer 统一处理(下载/解压/发现/tree SHA/原子 promote/lock 事务);插件其他模块只读。限流兜底的 codeload 扫描只在 `os.tmpdir()` 里解压,结果绝不落进 canonical 或 lock。
72
72
  - **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
73
- - **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次 `add` 覆盖。
73
+ - **启停不改正文**:`mode` 只增删 DSH 侧链接,避免本地修改被下一次安装/更新覆盖。
74
74
  - **可回滚**:删除进回收站(7 天);更新前快照到 `backups/`,失败自动还原(接口另提供手动回滚)。
75
75
  - **状态坏了就只读**:`state.json` 是坏 JSON 或版本未知时,插件降级为只读并让写接口返回 409,不猜结构、不自动修复。
76
76
  - **不做任意路径**:请求里的目标一律走注册表反查,软链目标恒为 canonical 下同名目录。
@@ -79,8 +79,8 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
79
79
 
80
80
  | 项 | 路径 | 归属 |
81
81
  | --- | --- | --- |
82
- | canonical 真身 | `~/.agents/skills` | `skills` CLI 独占 |
83
- | CLI lock | `~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`) | `skills` CLI 独占(本插件只读) |
82
+ | canonical 真身 | `~/.agents/skills` | native installer(安装/删除/更新) |
83
+ | v3 lock | `~/.agents/.skill-lock.json`(设了 `XDG_STATE_HOME` 时为 `$XDG_STATE_HOME/skills/.skill-lock.json`) | native installer(安装/更新/删除) |
84
84
  | DSH 全局根 | `~/.dsh/skills` | 本插件写软链 |
85
85
  | 插件状态 | `$DSH_HOME/skills-console/state.json` | 本插件(`DSH_SKILLS_CONSOLE_HOME` 可覆盖) |
86
86
  | 备份 / 回收站 | `$DSH_HOME/skills-console/{backups,trash}` | 本插件 |
@@ -91,17 +91,12 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
91
91
  | 变量 | 默认 | 作用 |
92
92
  | --- | --- | --- |
93
93
  | `DSH_SKILLS_CONSOLE_HOME` | `$DSH_HOME/skills-console` | 状态、备份、回收站与事件日志的根 |
94
- | `DSH_SKILLS_CONSOLE_CLI` | 自动定位 | 覆盖 `skills` CLI(测试桩也走这里) |
95
- | `DSH_SKILLS_CONSOLE_NPX_SPEC` | `skills` | 仅 legacy `npx` 兜底用;桌面端没有 npx,正常应显示 `dependency` |
96
- | `DSH_SKILLS_CONSOLE_TIMEOUT_MS` | `120000` | 单次 CLI 调用超时 |
97
- | `DSH_SKILLS_CONSOLE_GIT` | `auto` | 设为 `off` 关闭 git 通道,强制只走 GitHub REST |
98
- | `DSH_SKILLS_CONSOLE_GIT_TIMEOUT_MS` | `60000` | git 单次操作(`ls-remote` / `clone`)超时 |
99
- | `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时 |
100
- | `XDG_STATE_HOME` | 未设置 | 设置后 CLI lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
94
+ | `GITHUB_TOKEN` / `GH_TOKEN` | 无 | 只读环境变量;带上后 REST 配额从匿名 60 次/小时提到 5000 次/小时,私有仓库安装也靠它 |
95
+ | `XDG_STATE_HOME` | 未设置 | 设置后 lock 移到 `$XDG_STATE_HOME/skills/.skill-lock.json`,插件跟随读取 |
101
96
 
102
- 关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件默认让探测、更新检查与 git 兜底走系统 `git`(`ls-remote` + `clone --filter=blob:none --no-checkout`),不占 REST 配额、也不需要 token;git 不可用时才回退 REST,所以原本能用的环境不会因为这条路径变坏。限流且 git 也不可用时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`。
97
+ 关于 GitHub 配额:**匿名 REST 是 60 次/小时,按出口 IP 共享**(公司、校园网出口常被同网段的人一起打满),打满后探测与更新会收到 403。本插件先走 REST;403/429 时改用 **Atom commit feed** 取 commit,再用 **codeload tarball** 在临时目录解压、扫描并本地计算 tree SHA,不占 REST 配额、也不需要 git。兜底也失败时,错误文案会给出恢复时间,并提示设置 `GITHUB_TOKEN`(5000 次/小时)。
103
98
 
104
- > git 通道只在 `os.tmpdir()` 里读一份临时仓库,用完即删 —— 不写 canonical、不写 CLI lock,落盘始终只交给 `skills` CLI。
99
+ > Atom/codeload 兜底只在 `os.tmpdir()` 里解压,用完即删;结果不会落进 canonical 或 lock。
105
100
 
106
101
 
107
102
  ## 开发与发布
@@ -0,0 +1,246 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as tar from 'tar';
4
+
5
+ /**
6
+ * 安全解压 codeload tarball。分两遍:**先全量校验,再解压**。
7
+ *
8
+ * 为什么不在解压时用 filter 跳过坏条目:filter 返回 false 只是“不落盘”,进程仍然算成功,
9
+ * 恶意归档会被静默忽略而不是拒绝。安装器需要 fail closed,所以先 list 一遍:
10
+ * - 只接受普通文件与目录;symlink/hardlink/设备/FIFO/未知类型直接拒绝。
11
+ * - 拒绝绝对路径、盘符、反斜杠、`.` / `..` / 空段。
12
+ * - 整个归档必须只有一个顶层目录(codeload 的 `repo-ref/`),用 strip:1 剥掉。
13
+ * - 文件数 ≤ maxFiles、解压总量 ≤ maxBytes;超限立即中止。
14
+ *
15
+ * 解压后再扫一遍目录,确认没有符号链接或特殊文件落盘(防御性复核)。
16
+ * 所有失败抛 ArchiveError,调用方负责清理 staging。
17
+ */
18
+
19
+ export const DEFAULT_MAX_FILES = 10_000;
20
+ export const DEFAULT_MAX_EXTRACTED_BYTES = 200 * 1024 * 1024;
21
+
22
+ export class ArchiveError extends Error {
23
+ constructor(code, message, extra = {}) {
24
+ super(message);
25
+ this.name = 'ArchiveError';
26
+ this.code = code;
27
+ Object.assign(this, extra);
28
+ }
29
+ }
30
+
31
+ function isGzipFile(file) {
32
+ const fd = fs.openSync(file, 'r');
33
+ try {
34
+ const head = Buffer.alloc(2);
35
+ const read = fs.readSync(fd, head, 0, 2, 0);
36
+ return read === 2 && head[0] === 0x1f && head[1] === 0x8b;
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 归一化并校验一个 tar 条目路径。返回 `{ value, segments }`:
44
+ * - `value` 去掉末尾 `/` 后的 POSIX 路径;
45
+ * - 根目录条目(`repo-ref/`)的 segments 为 `['repo-ref']`。
46
+ */
47
+ function parseEntryPath(raw) {
48
+ if (typeof raw !== 'string' || raw.length === 0) {
49
+ throw new ArchiveError('unsafe-entry', `条目路径为空或不是字符串: ${String(raw)}`);
50
+ }
51
+ if (raw.includes('\0')) throw new ArchiveError('unsafe-entry', '条目路径含 NUL');
52
+ if (raw.includes('\\')) {
53
+ throw new ArchiveError('unsafe-entry', `条目路径含反斜杠: ${JSON.stringify(raw)}`);
54
+ }
55
+ if (raw.startsWith('/')) throw new ArchiveError('unsafe-entry', `条目是绝对路径: ${raw}`);
56
+ if (/^[A-Za-z]:/.test(raw)) throw new ArchiveError('unsafe-entry', `条目带盘符: ${raw}`);
57
+
58
+ let value = raw;
59
+ while (value.endsWith('/')) value = value.slice(0, -1);
60
+
61
+ const segments = value.split('/');
62
+ for (const segment of segments) {
63
+ if (segment === '' || segment === '.' || segment === '..') {
64
+ throw new ArchiveError('unsafe-entry', `条目路径含非法段: ${JSON.stringify(raw)}`);
65
+ }
66
+ // eslint-disable-next-line no-control-regex
67
+ if (/[\u0000-\u001f\u007f]/.test(segment)) {
68
+ throw new ArchiveError('unsafe-entry', `条目路径含控制字符: ${JSON.stringify(raw)}`);
69
+ }
70
+ }
71
+ return { raw, value, segments };
72
+ }
73
+
74
+ /** 扫一遍 tar 头,全部合法才允许进入解压。 */
75
+ async function scanArchive({ tarball, gzip, maxFiles, maxBytes }) {
76
+ let root = null;
77
+ let files = 0;
78
+ let dirs = 0;
79
+ let bytes = 0;
80
+ let failure = null;
81
+ const warnings = [];
82
+
83
+ // 注意:tar 把 onReadEntry 里的异常当同步异常抛到事件循环,不会 reject list 的 Promise。
84
+ // 所以这里先记下第一个校验错误,扫完再抛;有错误时后续条目直接跳过。
85
+ const onReadEntry = (entry) => {
86
+ if (failure) return;
87
+ try {
88
+ const pathInfo = parseEntryPath(entry.path);
89
+ const [top] = pathInfo.segments;
90
+ if (root === null) root = top;
91
+ if (top !== root) {
92
+ throw new ArchiveError('unsafe-entry', `归档里有多个顶层目录: ${root} / ${top}`);
93
+ }
94
+
95
+ if (pathInfo.value === root) {
96
+ // 顶层目录本身;codeload 里它总是 Directory。
97
+ if (entry.type !== 'Directory') {
98
+ throw new ArchiveError('unsafe-entry', `顶层条目不是目录: ${entry.path}`);
99
+ }
100
+ dirs += 1;
101
+ return;
102
+ }
103
+
104
+ if (entry.type === 'File') {
105
+ if (!Number.isFinite(entry.size) || entry.size < 0) {
106
+ throw new ArchiveError('invalid-archive', `条目 size 非法: ${entry.path}`);
107
+ }
108
+ files += 1;
109
+ bytes += entry.size;
110
+ if (files > maxFiles) {
111
+ throw new ArchiveError('too-many-files', `文件数超过 ${maxFiles} 上限`, { maxFiles });
112
+ }
113
+ if (bytes > maxBytes) {
114
+ throw new ArchiveError('archive-too-large', `解压总量超过 ${maxBytes} 字节上限`, {
115
+ maxBytes,
116
+ bytes,
117
+ });
118
+ }
119
+ return;
120
+ }
121
+
122
+ if (entry.type === 'Directory') {
123
+ dirs += 1;
124
+ return;
125
+ }
126
+
127
+ throw new ArchiveError('unsafe-entry', `拒绝的条目类型 ${entry.type}: ${entry.path}`, {
128
+ entryType: entry.type,
129
+ entryPath: entry.path,
130
+ });
131
+ } catch (error) {
132
+ failure = error;
133
+ }
134
+ };
135
+
136
+ const onwarn = (code) => {
137
+ // 校验和失败 / 无法识别的归档 / 解压比例异常都说明归档不完整或伪造。
138
+ if (code === 'TAR_BAD_ARCHIVE' || code === 'TAR_ENTRY_INVALID' || code === 'TAR_ABORT') {
139
+ warnings.push(code);
140
+ }
141
+ };
142
+
143
+ try {
144
+ await tar.list({ file: tarball, gzip, onReadEntry, onwarn });
145
+ } catch (error) {
146
+ if (failure) throw failure;
147
+ throw new ArchiveError('invalid-archive', `不是合法的 tar 归档: ${error.message}`, { cause: error });
148
+ }
149
+ if (failure) throw failure;
150
+ if (warnings.length > 0) {
151
+ throw new ArchiveError('invalid-archive', `归档有损坏条目: ${warnings[0]}`, { warnings });
152
+ }
153
+
154
+ if (root === null) throw new ArchiveError('empty-archive', '归档里没有任何条目');
155
+ if (files === 0) throw new ArchiveError('empty-archive', '归档里没有普通文件');
156
+ return { root, files, dirs, bytes };
157
+ }
158
+
159
+ /** 解压后复核:不允许任何 symlink / 设备 / FIFO 落盘。 */
160
+ function verifyExtracted(dest) {
161
+ let files = 0;
162
+ let dirs = 0;
163
+ const walk = (dir) => {
164
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
165
+ const full = path.join(dir, entry.name);
166
+ const stat = fs.lstatSync(full);
167
+ if (stat.isSymbolicLink()) {
168
+ throw new ArchiveError('unsafe-entry', `解压后出现符号链接: ${full}`);
169
+ }
170
+ if (stat.isDirectory()) {
171
+ dirs += 1;
172
+ walk(full);
173
+ continue;
174
+ }
175
+ if (stat.isFile()) {
176
+ files += 1;
177
+ continue;
178
+ }
179
+ throw new ArchiveError('unsafe-entry', `解压后出现特殊文件: ${full}`);
180
+ }
181
+ };
182
+ walk(dest);
183
+ return { files, dirs };
184
+ }
185
+
186
+ /**
187
+ * 解压 tarball 到 `dest`。`dest` 必须是空目录或不存在(不存在会自动创建)。
188
+ * 成功返回 `{ dir, root, files, dirs, bytes }`;失败抛 ArchiveError,由调用方清理。
189
+ */
190
+ export async function extractTarball({
191
+ tarball,
192
+ dest,
193
+ maxFiles = DEFAULT_MAX_FILES,
194
+ maxBytes = DEFAULT_MAX_EXTRACTED_BYTES,
195
+ gzip,
196
+ } = {}) {
197
+ if (typeof tarball !== 'string' || typeof dest !== 'string') {
198
+ throw new ArchiveError('invalid-argument', 'extractTarball 需要 tarball 与 dest 路径');
199
+ }
200
+ if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
201
+ throw new ArchiveError('invalid-argument', 'maxFiles 必须是正整数');
202
+ }
203
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
204
+ throw new ArchiveError('invalid-argument', 'maxBytes 必须是正数');
205
+ }
206
+ let stat;
207
+ try {
208
+ stat = fs.statSync(tarball);
209
+ } catch (error) {
210
+ throw new ArchiveError('invalid-archive', `tarball 不存在: ${tarball}`, { cause: error });
211
+ }
212
+ if (!stat.isFile()) throw new ArchiveError('invalid-archive', `tarball 不是文件: ${tarball}`);
213
+
214
+ fs.mkdirSync(dest, { recursive: true });
215
+ const destStat = fs.lstatSync(dest);
216
+ if (destStat.isSymbolicLink() || !destStat.isDirectory()) {
217
+ throw new ArchiveError('invalid-dest', `staging 必须是真实目录: ${dest}`);
218
+ }
219
+ if (fs.readdirSync(dest).length > 0) {
220
+ throw new ArchiveError('dest-not-empty', `staging 目录必须为空: ${dest}`);
221
+ }
222
+
223
+ const compressed = gzip === undefined ? isGzipFile(tarball) : Boolean(gzip);
224
+ const scanned = await scanArchive({ tarball, gzip: compressed, maxFiles, maxBytes });
225
+
226
+ try {
227
+ await tar.extract({
228
+ file: tarball,
229
+ cwd: dest,
230
+ strip: 1,
231
+ gzip: compressed,
232
+ preservePaths: false,
233
+ });
234
+ } catch (error) {
235
+ throw new ArchiveError('extract-failed', `解压失败: ${error.message}`, { cause: error });
236
+ }
237
+
238
+ const verified = verifyExtracted(dest);
239
+ return {
240
+ dir: dest,
241
+ root: scanned.root,
242
+ files: verified.files,
243
+ dirs: verified.dirs,
244
+ bytes: scanned.bytes,
245
+ };
246
+ }
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readSkillFrontmatter } from './skill-md.js';
4
+
5
+ /**
6
+ * 解压目录里的技能发现(纯只读,不落盘)。
7
+ *
8
+ * 规则与设计 §槽位一致:
9
+ * - 遍历(含 subpath 前缀)所有 `SKILL.md`,每个所在目录算一个技能;
10
+ * - 名字优先 frontmatter `name`,没有则用目录名,根级用仓库名;
11
+ * - 名字必须非空且不含 `/`、`\`、`..`、控制字符;
12
+ * - 重名直接报错,绝不静默择一;
13
+ * - 根级 `SKILL.md` 的 `skillPath` 是 `SKILL.md`,嵌套的是相对仓库根的 POSIX 路径。
14
+ *
15
+ * 返回 `[{ name, skillPath, dirPath, description }]`,按 name 排序。
16
+ */
17
+
18
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
19
+
20
+ export class DiscoveryError extends Error {
21
+ constructor(code, message, extra = {}) {
22
+ super(message);
23
+ this.name = 'DiscoveryError';
24
+ this.code = code;
25
+ Object.assign(this, extra);
26
+ }
27
+ }
28
+
29
+ function toPosix(value) {
30
+ return value.split(path.sep).join('/');
31
+ }
32
+
33
+ function normalizeSubpath(subpath) {
34
+ if (subpath === null || subpath === undefined || subpath === '') return null;
35
+ if (typeof subpath !== 'string') throw new DiscoveryError('invalid-subpath', 'subpath 必须是字符串');
36
+ const cleaned = subpath.trim().replace(/^\/+|\/+$/g, '');
37
+ if (!cleaned) return null;
38
+ if (cleaned.includes('\\') || /^[A-Za-z]:/.test(cleaned)) {
39
+ throw new DiscoveryError('invalid-subpath', `subpath 不是 POSIX 相对路径: ${subpath}`);
40
+ }
41
+ const segments = cleaned.split('/');
42
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
43
+ throw new DiscoveryError('invalid-subpath', `subpath 含非法段: ${subpath}`);
44
+ }
45
+ return segments.join('/');
46
+ }
47
+
48
+ function assertSkillName(name, dirPath) {
49
+ if (typeof name !== 'string' || name.length === 0) {
50
+ throw new DiscoveryError('invalid-name', `技能名为空: ${dirPath}`, { dirPath });
51
+ }
52
+ if (name.length > 120) {
53
+ throw new DiscoveryError('invalid-name', `技能名过长: ${name}`, { name, dirPath });
54
+ }
55
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
56
+ throw new DiscoveryError('invalid-name', `技能名含路径分隔符: ${name}`, { name, dirPath });
57
+ }
58
+ // eslint-disable-next-line no-control-regex
59
+ if (/[\u0000-\u001f\u007f]/.test(name)) {
60
+ throw new DiscoveryError('invalid-name', `技能名含控制字符: ${JSON.stringify(name)}`, { name, dirPath });
61
+ }
62
+ return name;
63
+ }
64
+
65
+ /**
66
+ * @param {{ dir: string, subpath?: string|null, repoName?: string|null }} options
67
+ */
68
+ export function discoverSkills({ dir, subpath = null, repoName = null } = {}) {
69
+ if (typeof dir !== 'string' || dir.length === 0) {
70
+ throw new DiscoveryError('invalid-dir', 'discoverSkills 需要解压目录');
71
+ }
72
+ const root = path.resolve(dir);
73
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
74
+ throw new DiscoveryError('invalid-dir', `不是目录: ${dir}`);
75
+ }
76
+
77
+ const normalizedSubpath = normalizeSubpath(subpath);
78
+ const scanRoot = normalizedSubpath ? path.join(root, ...normalizedSubpath.split('/')) : root;
79
+ let scanStat;
80
+ try {
81
+ scanStat = fs.statSync(scanRoot);
82
+ } catch {
83
+ throw new DiscoveryError('subpath-not-found', `subpath 不存在: ${normalizedSubpath}`, {
84
+ subpath: normalizedSubpath,
85
+ });
86
+ }
87
+ if (!scanStat.isDirectory()) {
88
+ throw new DiscoveryError('subpath-not-found', `subpath 不是目录: ${normalizedSubpath}`, {
89
+ subpath: normalizedSubpath,
90
+ });
91
+ }
92
+
93
+ const fallbackRootName = repoName || path.basename(root);
94
+ const found = [];
95
+
96
+ const walk = (current) => {
97
+ let entries;
98
+ try {
99
+ entries = fs.readdirSync(current, { withFileTypes: true });
100
+ } catch (error) {
101
+ throw new DiscoveryError('read-failed', `无法读取 ${current}: ${error.message}`, { cause: error });
102
+ }
103
+
104
+ const skillFile = entries.find((entry) => entry.name === 'SKILL.md' && entry.isFile());
105
+ if (skillFile) {
106
+ const skillDir = current;
107
+ const skillMd = path.join(skillDir, 'SKILL.md');
108
+ const frontmatter = readSkillFrontmatter(skillDir);
109
+ const fallback = skillDir === root ? fallbackRootName : path.basename(skillDir);
110
+ const name = assertSkillName(
111
+ typeof frontmatter.name === 'string' && frontmatter.name.trim()
112
+ ? frontmatter.name.trim()
113
+ : fallback,
114
+ skillDir,
115
+ );
116
+ found.push({
117
+ name,
118
+ skillPath: toPosix(path.relative(root, skillMd)),
119
+ dirPath: skillDir,
120
+ description: typeof frontmatter.description === 'string' ? frontmatter.description : null,
121
+ });
122
+ }
123
+
124
+ for (const entry of entries) {
125
+ if (!entry.isDirectory()) continue;
126
+ if (SKIP_DIRS.has(entry.name)) continue;
127
+ // 隐藏目录(.github、.vscode 等)里不会有技能;跳过也能避开 macOS 的 .DS_Store 目录
128
+ if (entry.name.startsWith('.')) continue;
129
+ walk(path.join(current, entry.name));
130
+ }
131
+ };
132
+
133
+ walk(scanRoot);
134
+
135
+ const byName = new Map();
136
+ for (const skill of found) {
137
+ const previous = byName.get(skill.name);
138
+ if (previous) {
139
+ throw new DiscoveryError('duplicate-name', `发现重名技能 ${skill.name}`, {
140
+ name: skill.name,
141
+ paths: [previous.skillPath, skill.skillPath],
142
+ });
143
+ }
144
+ byName.set(skill.name, skill);
145
+ }
146
+
147
+ return found.sort((a, b) => a.name.localeCompare(b.name));
148
+ }
@@ -0,0 +1,132 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * 本地计算 Git tree SHA(40 位),与 `git write-tree` / GitHub Trees API 的 `sha` 同一算法。
7
+ *
8
+ * 用途:native installer 落盘后算技能目录的 `skillFolderHash`,与旧 CLI lock 的 40 位基线对齐。
9
+ * 语义边界(与 git 一致):
10
+ * - 文件 mode 只区分 `100644` / `100755`,目录 `40000`;不保留其他权限位。
11
+ * - 目录名排序按 git 的 `base_name_compare`:目录视作名字末尾多一个 `/` 再按字节比较。
12
+ * - 空目录不进 tree(git 根本不跟踪空目录),否则会与上游 tree SHA 对不上。
13
+ * - symlink、hardlink、设备、FIFO 等无法用普通 blob/tree 表示的类型**直接拒绝**,
14
+ * 绝不用内容 hash 冒充,避免写进 lock 的基线是猜的。
15
+ *
16
+ * 纯函数:只读传入目录,不写任何东西;失败抛 GitTreeError。
17
+ */
18
+
19
+ export const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
20
+
21
+ const MODE_FILE = '100644';
22
+ const MODE_EXEC = '100755';
23
+ const MODE_DIR = '40000';
24
+
25
+ export class GitTreeError extends Error {
26
+ constructor(code, message, extra = {}) {
27
+ super(message);
28
+ this.name = 'GitTreeError';
29
+ this.code = code;
30
+ Object.assign(this, extra);
31
+ }
32
+ }
33
+
34
+ function blobSha(content) {
35
+ const header = Buffer.from(`blob ${content.length}\0`, 'utf8');
36
+ return crypto.createHash('sha1').update(header).update(content).digest();
37
+ }
38
+
39
+ function treeShaOf(payload) {
40
+ const header = Buffer.from(`tree ${payload.length}\0`, 'utf8');
41
+ return crypto.createHash('sha1').update(header).update(payload).digest();
42
+ }
43
+
44
+ /** git 的 tree 排序:目录名末尾补 `/` 后按字节比较。 */
45
+ function compareEntries(left, right) {
46
+ const a = Buffer.from(left.dir ? `${left.name}/` : left.name, 'utf8');
47
+ const b = Buffer.from(right.dir ? `${right.name}/` : right.name, 'utf8');
48
+ return Buffer.compare(a, b);
49
+ }
50
+
51
+ function modeOf(stat) {
52
+ return (stat.mode & 0o111) !== 0 ? MODE_EXEC : MODE_FILE;
53
+ }
54
+
55
+ /**
56
+ * 递归读取目录,返回 `{ sha: Buffer, empty: boolean }`。
57
+ * `empty` 表示这个目录在 git 意义上是空的(没有文件,或只有空子目录)。
58
+ */
59
+ function hashDir(dir) {
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir, { withFileTypes: true });
63
+ } catch (error) {
64
+ throw new GitTreeError('read-failed', `无法读取目录 ${dir}: ${error.message}`, { cause: error });
65
+ }
66
+
67
+ const items = [];
68
+ for (const entry of entries) {
69
+ const full = path.join(dir, entry.name);
70
+ let stat;
71
+ try {
72
+ stat = fs.lstatSync(full);
73
+ } catch (error) {
74
+ throw new GitTreeError('read-failed', `无法读取 ${full}: ${error.message}`, { cause: error });
75
+ }
76
+
77
+ if (stat.isSymbolicLink()) {
78
+ throw new GitTreeError('unsupported-entry', `符号链接无法进 git tree: ${full}`, { path: full });
79
+ }
80
+ if (stat.isDirectory()) {
81
+ const nested = hashDir(full);
82
+ if (!nested.empty) items.push({ name: entry.name, dir: true, sha: nested.sha });
83
+ continue;
84
+ }
85
+ if (stat.isFile()) {
86
+ let content;
87
+ try {
88
+ content = fs.readFileSync(full);
89
+ } catch (error) {
90
+ throw new GitTreeError('read-failed', `无法读取 ${full}: ${error.message}`, { cause: error });
91
+ }
92
+ items.push({ name: entry.name, dir: false, mode: modeOf(stat), sha: blobSha(content) });
93
+ continue;
94
+ }
95
+ // FIFO / socket / 块设备 / 字符设备:git 里都不是普通条目,直接拒绝。
96
+ throw new GitTreeError('unsupported-entry', `不支持的文件类型,无法进 git tree: ${full}`, {
97
+ path: full,
98
+ });
99
+ }
100
+
101
+ items.sort(compareEntries);
102
+
103
+ const chunks = [];
104
+ for (const item of items) {
105
+ chunks.push(Buffer.from(`${item.mode || MODE_DIR} ${item.name}\0`, 'utf8'), item.sha);
106
+ }
107
+ const payload = Buffer.concat(chunks);
108
+ return { sha: treeShaOf(payload), empty: items.length === 0 };
109
+ }
110
+
111
+ /**
112
+ * 计算目录的 Git tree SHA(40 位小写 hex)。
113
+ * 目录不存在 / 不可读 / 含不可表示条目时抛 GitTreeError,调用方不得写 lock。
114
+ */
115
+ export function gitTreeSha(dir) {
116
+ if (typeof dir !== 'string' || dir.length === 0) {
117
+ throw new GitTreeError('invalid-path', 'gitTreeSha 需要非空目录路径');
118
+ }
119
+ let stat;
120
+ try {
121
+ stat = fs.lstatSync(dir);
122
+ } catch (error) {
123
+ throw new GitTreeError('read-failed', `目录不存在或不可读 ${dir}: ${error.message}`, { cause: error });
124
+ }
125
+ if (stat.isSymbolicLink()) {
126
+ throw new GitTreeError('unsupported-entry', `技能目录本身是符号链接: ${dir}`, { path: dir });
127
+ }
128
+ if (!stat.isDirectory()) {
129
+ throw new GitTreeError('invalid-path', `不是目录: ${dir}`);
130
+ }
131
+ return hashDir(dir).sha.toString('hex');
132
+ }