@morehao/dsh-skills-console 0.3.0 → 0.3.2

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.
@@ -75,7 +75,7 @@ export function readFrontmatterDescription(skillPath) {
75
75
  }
76
76
  }
77
77
 
78
- function statusOf({ install, check, locallyModified, brokenLinks }) {
78
+ function statusOf({ install, check, locallyModified, brokenLinks, subscribed }) {
79
79
  if (!install) return 'unmanaged';
80
80
  if (locallyModified && check?.status === 'update-available') return 'conflict';
81
81
  if (locallyModified) return 'locally-modified';
@@ -83,6 +83,9 @@ function statusOf({ install, check, locallyModified, brokenLinks }) {
83
83
  if (check?.status === 'update-available') return 'update-available';
84
84
  if (brokenLinks.length > 0) return 'broken-link';
85
85
  if (check?.status === 'current') return 'current';
86
+ // 没有可用的上游订阅 = 永远拿不到更新结论(检查更新只覆盖有订阅的技能)。
87
+ // 这跟「这次没判定出来」是两件事,混进 unknown 会让待检查计数永远降不下来。
88
+ if (!subscribed) return 'no-upstream';
86
89
  return 'unknown';
87
90
  }
88
91
 
@@ -94,6 +97,9 @@ export function buildLedger({ paths, state, registry, lock, visibleNames = new M
94
97
  const scanned = scanCanonical(paths);
95
98
  const targets = registry.detected();
96
99
  const installs = new Map(state.installs.map((item) => [item.name, item]));
100
+ // 只有「state 里真有这条订阅记录」才算可检查:订阅被删掉(keepInstalled)后
101
+ // install.subscriptionId 会指向一个不存在的 id,那种行同样永远等不到结论。
102
+ const subscriptions = new Set((state.subscriptions || []).map((item) => item.id));
97
103
 
98
104
  const rows = scanned.map((entry) => {
99
105
  const install = installs.get(entry.name);
@@ -129,6 +135,7 @@ export function buildLedger({ paths, state, registry, lock, visibleNames = new M
129
135
 
130
136
  const check = state.checks?.[entry.name] || null;
131
137
  const visibleSource = visibleNames.get(entry.name);
138
+ const subscribed = Boolean(install?.subscriptionId) && subscriptions.has(install.subscriptionId);
132
139
  return {
133
140
  name: entry.name,
134
141
  path: entry.path,
@@ -155,7 +162,7 @@ export function buildLedger({ paths, state, registry, lock, visibleNames = new M
155
162
  backup: install?.backup ?? null,
156
163
  links,
157
164
  brokenLinks: brokenLinks.length,
158
- status: statusOf({ install, check, locallyModified, brokenLinks }),
165
+ status: statusOf({ install, check, locallyModified, brokenLinks, subscribed }),
159
166
  check,
160
167
  visible: visibleNames.has(entry.name),
161
168
  visibleSource: visibleSource ?? null,
@@ -7,6 +7,7 @@ import { buildLedger, readLock, scanCanonical } from './inventory.js';
7
7
  import { readLockFile, updateLock } from './lock.js';
8
8
  import { readBackup, restoreBackup, trashSkill } from './trash.js';
9
9
  import { parseSkillFrontmatter } from './skill-md.js';
10
+ import { readSkillFile, readSkillTree, resolveSkillRoot, SkillFileError } from './skill-files.js';
10
11
  import {
11
12
  checkSkill,
12
13
  fetchDefaultBranch,
@@ -35,6 +36,23 @@ function nowIso() {
35
36
  return new Date().toISOString();
36
37
  }
37
38
 
39
+ /**
40
+ * 上次检查是否已经得出可用结论。`unknown` 不是结论(是「没能判定」),
41
+ * 所以 304 时不能拿它当「上次结论」沿用,否则待检查会一直粘着。
42
+ */
43
+ function isSettledCheck(check) {
44
+ const status = check?.status;
45
+ return status === 'current' || status === 'update-available' || status === 'upstream-deleted';
46
+ }
47
+
48
+ /** 读取层错误 → ServiceError(路由只认 ServiceError → 400),保留原 code / detail。 */
49
+ function skillFileFailure(error) {
50
+ if (error instanceof SkillFileError) {
51
+ return new ServiceError(error.code, error.message, error.detail ?? null);
52
+ }
53
+ return error;
54
+ }
55
+
38
56
  /** 路由参数里的技能名:在拼任何路径之前先校验,拒绝穿越/分隔符/控制字符。 */
39
57
  function assertSafeSkillName(name) {
40
58
  if (
@@ -710,22 +728,52 @@ export function createService({
710
728
  return { ok: true, ...linkSkill(skill, targets, { scopeProjects }) };
711
729
  },
712
730
 
713
- async listSkillBody({ name }) {
714
- const candidateDirs = [path.join(paths.canonical, name), path.join(paths.canonical, `${name}.md`)];
715
- for (const candidate of candidateDirs) {
716
- try {
717
- const stat = fs.statSync(candidate);
718
- if (stat.isDirectory()) {
719
- const body = await fs.promises.readFile(path.join(candidate, 'SKILL.md'), 'utf8');
720
- return { ok: true, name, content: body, path: path.join(candidate, 'SKILL.md') };
721
- }
722
- const body = await fs.promises.readFile(candidate, 'utf8');
723
- return { ok: true, name, content: body, path: candidate };
724
- } catch {
725
- // 继续找下一种形态
726
- }
731
+ /**
732
+ * 读技能正文 / 单个文件(只读):`path` 是技能根内的相对路径,省略时读 SKILL.md(平铺读 `<name>.md`)。
733
+ * 技能名与相对路径都在拼路径之前校验;越界一律 400,不落文件系统。
734
+ */
735
+ async listSkillBody({ name, path: relPath }) {
736
+ assertSafeSkillName(name);
737
+ try {
738
+ const root = await resolveSkillRoot(paths, name);
739
+ const file = await readSkillFile({ root: root.root, kind: root.kind, relPath });
740
+ return {
741
+ ok: true,
742
+ name,
743
+ kind: root.kind,
744
+ relPath: file.relPath,
745
+ path: file.path,
746
+ content: file.content,
747
+ meta: parseSkillFrontmatter(file.content),
748
+ size: file.size,
749
+ truncated: file.truncated,
750
+ mtime: file.mtime,
751
+ };
752
+ } catch (error) {
753
+ throw skillFileFailure(error);
754
+ }
755
+ },
756
+
757
+ /** 列技能目录的文件树(只读,不跟随符号链接)。 */
758
+ async listSkillFiles({ name }) {
759
+ assertSafeSkillName(name);
760
+ try {
761
+ const root = await resolveSkillRoot(paths, name);
762
+ const tree = await readSkillTree({ root: root.root, kind: root.kind });
763
+ return {
764
+ ok: true,
765
+ name,
766
+ root: tree.root,
767
+ kind: tree.kind,
768
+ body: tree.body,
769
+ hasSkillMd: tree.hasSkillMd,
770
+ files: tree.files,
771
+ totalBytes: tree.totalBytes,
772
+ truncated: tree.truncated,
773
+ };
774
+ } catch (error) {
775
+ throw skillFileFailure(error);
727
776
  }
728
- throw new ServiceError('error.skills.not-found', `${name} 不在 canonical 里`);
729
777
  },
730
778
 
731
779
  /**
@@ -1238,6 +1286,30 @@ export function createService({
1238
1286
  }
1239
1287
  continue;
1240
1288
  }
1289
+ // 304 只说明「树没变」,它本身不含任何树内容,能不能沿用取决于**上次有没有结论**。
1290
+ // 上次是 unknown(更早一次探测失败留下的)时,沿用就等于把「待检查」永久粘住:
1291
+ // 用户点多少次刷新都还是 unknown。此时只能无视 ETag 完整读一次,才拿得到真判定。
1292
+ if (probe.notModified && groupInstalls.some((install) => !isSettledCheck(state().checks?.[install.name]))) {
1293
+ try {
1294
+ probe = await readSnapshot(subscription, {
1295
+ force: true,
1296
+ withCommit: false,
1297
+ persistRef: true,
1298
+ allowUnchanged: false,
1299
+ });
1300
+ } catch (error) {
1301
+ for (const install of groupInstalls) {
1302
+ results.push({
1303
+ name: install.name,
1304
+ status: 'unknown',
1305
+ detail: error.message,
1306
+ code: error.code || null,
1307
+ checkedAt: nowIso(),
1308
+ });
1309
+ }
1310
+ continue;
1311
+ }
1312
+ }
1241
1313
  if (probe.notModified) {
1242
1314
  for (const install of groupInstalls) {
1243
1315
  const previousCheck = state().checks?.[install.name];
@@ -0,0 +1,336 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isUtf8 } from 'node:buffer';
4
+
5
+ import { isInside } from './paths.js';
6
+
7
+ /**
8
+ * 技能正文/文件的只读读取层:只做文件系统,不依赖 service,避免循环引用。
9
+ *
10
+ * 安全边界(与 AGENTS.md「不接收任意路径」一致):
11
+ * - 根只从 canonical 反查:`<canonical>/<name>/` 或 `<canonical>/<name>.md`,不接受外部路径;
12
+ * - 相对路径逐段校验,拒绝绝对路径 / 盘符 / 反斜杠 / NUL / `..`;
13
+ * - lexic 包含判断 + realpath 包含判断双保险,符号链接一律不跟随(`lstat` + `O_NOFOLLOW` + `fstat`);
14
+ * - 只读:本模块从不写 canonical / lock。
15
+ *
16
+ * 错误一律抛 SkillFileError,由 Service 包成 ServiceError(HTTP 400)。
17
+ */
18
+
19
+ /** 单文件读取上限:超出只读前 256KB,并置 `truncated`。 */
20
+ export const MAX_FILE_BYTES = 256 * 1024;
21
+ /** 文件树条目上限,超过置 `truncated`。 */
22
+ export const MAX_TREE_ENTRIES = 500;
23
+ /** 文件树递归深度上限。 */
24
+ export const MAX_TREE_DEPTH = 8;
25
+ /** 相对路径长度上限。 */
26
+ export const MAX_REL_PATH = 300;
27
+ /** 二进制嗅探窗口:前 8KB 出现 NUL 即视为二进制。 */
28
+ const SNIFF_BYTES = 8 * 1024;
29
+
30
+ /**
31
+ * 明确不预览的扩展名(图片 / 音视频 / 压缩包 / 字体 / 可执行与二进制数据)。
32
+ * 其余扩展名一律按内容判定:能解码成 UTF-8 且没有 NUL 就当源码显示,
33
+ * 这样 `.html` / `.jsx` / `.css` / 无扩展名的脚本也能看。
34
+ */
35
+ export const BINARY_EXTENSIONS = new Set([
36
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.icns', '.tif', '.tiff', '.avif', '.heic',
37
+ '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.aac', '.mp4', '.mov', '.webm', '.avi', '.mkv',
38
+ '.zip', '.gz', '.tgz', '.tar', '.bz2', '.xz', '.7z', '.rar',
39
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
40
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
41
+ '.so', '.dylib', '.dll', '.exe', '.dmg', '.wasm', '.class', '.jar', '.o', '.a', '.pyc',
42
+ '.db', '.sqlite', '.sqlite3', '.ds_store',
43
+ ]);
44
+
45
+ /** 文件树里永不进入的目录名。 */
46
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
47
+
48
+ export class SkillFileError extends Error {
49
+ constructor(code, message, detail) {
50
+ super(message);
51
+ this.name = 'SkillFileError';
52
+ this.code = code;
53
+ this.detail = detail ?? null;
54
+ }
55
+ }
56
+
57
+ function badPath(message, detail) {
58
+ return new SkillFileError('error.skills.bad-path', message, detail);
59
+ }
60
+
61
+ async function lstatOrNull(target) {
62
+ try {
63
+ return await fs.promises.lstat(target);
64
+ } catch (error) {
65
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
66
+ throw new SkillFileError('error.skills.file-unreadable', `无法读取 ${target}`, error?.code || null);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 相对路径归一化:只接受技能根内的 POSIX 相对路径。
72
+ * `allowEmpty` 用于「默认读 SKILL.md」。
73
+ */
74
+ export function normalizeRelPath(relPath, { allowEmpty = false } = {}) {
75
+ if (relPath === undefined || relPath === null) {
76
+ if (allowEmpty) return '';
77
+ throw badPath('缺少文件路径');
78
+ }
79
+ if (typeof relPath !== 'string') throw badPath('文件路径必须是字符串');
80
+ if (relPath.length === 0) {
81
+ if (allowEmpty) return '';
82
+ throw badPath('文件路径不能为空');
83
+ }
84
+ if (relPath.length > MAX_REL_PATH) throw badPath('文件路径过长');
85
+ if (relPath.includes('\u0000')) throw badPath('文件路径不能包含 NUL');
86
+ if (relPath.includes('\\')) throw badPath('文件路径不能包含反斜杠');
87
+ if (/^[A-Za-z]:/.test(relPath)) throw badPath('不接受绝对路径');
88
+ if (path.posix.isAbsolute(relPath)) throw badPath('不接受绝对路径');
89
+
90
+ const segments = relPath.split('/');
91
+ if (segments.some((segment) => segment === '..')) throw badPath('文件路径不能越出技能目录');
92
+
93
+ // 归一化后再检查一次:`a//b`、`./a` 之类的变体归一后仍必须留在根内
94
+ const normalized = path.posix.normalize(relPath);
95
+ if (normalized === '.') return allowEmpty ? '' : badPath('文件路径不能为空');
96
+ if (path.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) {
97
+ throw badPath('文件路径不能越出技能目录');
98
+ }
99
+ if (normalized.split('/').includes('..')) throw badPath('文件路径不能越出技能目录');
100
+ return normalized;
101
+ }
102
+
103
+ /**
104
+ * 反查技能根:目录包(`<canonical>/<name>/`)或平铺(`<canonical>/<name>.md`)。
105
+ * 根自身是符号链接时直接拒绝(v1 不跟随根链);两种形态都不存在 → not-found。
106
+ */
107
+ export async function resolveSkillRoot(paths, name) {
108
+ const canonical = path.resolve(paths.canonical);
109
+ const dirPath = path.join(canonical, name);
110
+ const filePath = path.join(canonical, `${name}.md`);
111
+
112
+ const dirStat = await lstatOrNull(dirPath);
113
+ if (dirStat) {
114
+ if (dirStat.isSymbolicLink()) throw badPath(`${name} 在 canonical 里是符号链接,拒绝读取`);
115
+ if (dirStat.isDirectory()) {
116
+ return { name, kind: 'dir', root: dirPath, body: 'SKILL.md', canonical };
117
+ }
118
+ }
119
+
120
+ const fileStat = await lstatOrNull(filePath);
121
+ if (fileStat) {
122
+ if (fileStat.isSymbolicLink()) throw badPath(`${name}.md 是符号链接,拒绝读取`);
123
+ if (fileStat.isFile()) {
124
+ return { name, kind: 'file', root: filePath, body: path.basename(filePath), canonical };
125
+ }
126
+ }
127
+
128
+ throw new SkillFileError('error.skills.not-found', `${name} 不在 canonical 里`);
129
+ }
130
+
131
+ /** 把相对路径解析成受控的绝对路径;lexic 检查通过后再用 realpath 复核。 */
132
+ async function resolveInside(root, relPath) {
133
+ const target = path.resolve(root, relPath);
134
+ if (!isInside(root, target)) throw badPath('文件路径越出技能目录', { relPath });
135
+ // realpath 复核:中间层目录可能是符号链接,lexic 判断看不出越界
136
+ let realRoot;
137
+ let realTarget;
138
+ try {
139
+ realRoot = await fs.promises.realpath(root);
140
+ realTarget = await fs.promises.realpath(target);
141
+ } catch (error) {
142
+ if (error?.code === 'ENOENT') throw new SkillFileError('error.skills.file-not-found', `${relPath} 不存在`);
143
+ if (error?.code === 'ELOOP') throw badPath('文件路径经过符号链接,拒绝读取');
144
+ throw new SkillFileError('error.skills.file-unreadable', `无法读取 ${relPath}`, error?.code || null);
145
+ }
146
+ if (!isInside(realRoot, realTarget)) throw badPath('文件路径越出技能目录', { relPath });
147
+ return target;
148
+ }
149
+
150
+ function notPreviewable(relPath, detail) {
151
+ return new SkillFileError('error.skills.binary', `${relPath} 暂不支持预览`, detail ?? null);
152
+ }
153
+
154
+ /** 内容判定:已知二进制扩展名直接拒绝;其余看前 8KB 有没有 NUL、整体能不能按 UTF-8 解码。 */
155
+ function assertPreviewable(buffer, relPath, truncated = false) {
156
+ const ext = path.extname(relPath).toLowerCase();
157
+ if (BINARY_EXTENSIONS.has(ext)) throw notPreviewable(relPath, { ext });
158
+ const window = buffer.subarray(0, Math.min(buffer.length, SNIFF_BYTES));
159
+ if (window.includes(0)) throw notPreviewable(relPath, { reason: 'nul' });
160
+ // 截断缓冲区可能切在多字节字符中间:只验嗅探窗口,避免把正常文本误判成二进制
161
+ const sample = truncated ? window : buffer;
162
+ if (sample.length > 0 && !isUtf8(sample)) throw notPreviewable(relPath, { reason: 'encoding' });
163
+ }
164
+
165
+ /**
166
+ * 读单个文件。返回 `{ relPath, path, content, size, truncated, mtime }`,`size` 是真实大小。
167
+ * 平铺技能只允许读它自己那一个文件。
168
+ */
169
+ export async function readSkillFile({ root, kind = 'dir', relPath }) {
170
+ const relative = normalizeRelPath(relPath, { allowEmpty: true });
171
+ let target;
172
+ let effective;
173
+
174
+ if (kind === 'file') {
175
+ const base = path.basename(root);
176
+ if (relative !== '' && relative !== base) {
177
+ throw badPath('平铺技能只有一个文件', { relPath: relative });
178
+ }
179
+ effective = base;
180
+ target = root;
181
+ } else {
182
+ effective = relative === '' ? 'SKILL.md' : relative;
183
+ try {
184
+ target = await resolveInside(root, effective);
185
+ } catch (error) {
186
+ // 目录包缺 SKILL.md 是最常见的状态,单独给一个码,前端据此提示「DSH 读不到这个技能」
187
+ if (error?.code === 'error.skills.file-not-found' && effective === 'SKILL.md') {
188
+ throw new SkillFileError('error.skills.no-skill-md', '这个技能目录里没有 SKILL.md');
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ const stat = await lstatOrNull(target);
195
+ if (!stat) {
196
+ if (kind === 'dir' && effective === 'SKILL.md') {
197
+ throw new SkillFileError('error.skills.no-skill-md', '这个技能目录里没有 SKILL.md');
198
+ }
199
+ throw new SkillFileError('error.skills.file-not-found', `${effective} 不存在`);
200
+ }
201
+ if (stat.isSymbolicLink()) throw badPath(`${effective} 是符号链接,拒绝读取`);
202
+ if (!stat.isFile()) throw new SkillFileError('error.skills.not-a-file', `${effective} 不是普通文件`);
203
+
204
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
205
+ let handle;
206
+ try {
207
+ handle = await fs.promises.open(target, flags);
208
+ } catch (error) {
209
+ if (error?.code === 'ENOENT') throw new SkillFileError('error.skills.file-not-found', `${effective} 不存在`);
210
+ if (error?.code === 'ELOOP') throw badPath(`${effective} 是符号链接,拒绝读取`);
211
+ if (error?.code === 'EISDIR') throw new SkillFileError('error.skills.not-a-file', `${effective} 不是普通文件`);
212
+ throw new SkillFileError('error.skills.file-unreadable', `无法读取 ${effective}`, error?.code || null);
213
+ }
214
+
215
+ try {
216
+ const opened = await handle.stat();
217
+ if (!opened.isFile()) throw new SkillFileError('error.skills.not-a-file', `${effective} 不是普通文件`);
218
+ const size = opened.size;
219
+ const truncated = size > MAX_FILE_BYTES;
220
+ const length = Math.min(size, MAX_FILE_BYTES);
221
+ const buffer = Buffer.alloc(length);
222
+ if (length > 0) await handle.read(buffer, 0, length, 0);
223
+ assertPreviewable(buffer, effective, truncated);
224
+ const decoded = buffer.toString('utf8');
225
+ return {
226
+ relPath: effective,
227
+ path: target,
228
+ content: decoded.startsWith('\uFEFF') ? decoded.slice(1) : decoded,
229
+ size,
230
+ truncated,
231
+ mtime: opened.mtime.toISOString(),
232
+ };
233
+ } finally {
234
+ await handle.close().catch(() => {});
235
+ }
236
+ }
237
+
238
+ /**
239
+ * 列技能目录的文件树:递归、深度 ≤ 8、条目 ≤ 500。
240
+ * 跳过符号链接、点文件、`node_modules`、`.git`;目录在前、文件在后,各自按名称排序。
241
+ * 平铺技能返回单文件树。
242
+ */
243
+ export async function readSkillTree({ root, kind = 'dir' }) {
244
+ if (kind === 'file') {
245
+ const stat = await lstatOrNull(root);
246
+ if (!stat) throw new SkillFileError('error.skills.file-not-found', `${path.basename(root)} 不存在`);
247
+ if (stat.isSymbolicLink()) throw badPath(`${path.basename(root)} 是符号链接,拒绝读取`);
248
+ const name = path.basename(root);
249
+ const size = stat.size;
250
+ return {
251
+ root,
252
+ kind: 'file',
253
+ body: name,
254
+ hasSkillMd: true,
255
+ files: [{ path: name, kind: 'file', size }],
256
+ totalBytes: size,
257
+ truncated: false,
258
+ };
259
+ }
260
+
261
+ const files = [];
262
+ let totalBytes = 0;
263
+ let truncated = false;
264
+
265
+ async function walk(dir, prefix, depth) {
266
+ if (truncated) return;
267
+ if (depth > MAX_TREE_DEPTH) {
268
+ truncated = true;
269
+ return;
270
+ }
271
+ let dirents;
272
+ try {
273
+ dirents = await fs.promises.readdir(dir, { withFileTypes: true });
274
+ } catch {
275
+ return;
276
+ }
277
+ const dirs = [];
278
+ const plain = [];
279
+ for (const dirent of dirents) {
280
+ const name = dirent.name;
281
+ if (!name || name.startsWith('.')) continue;
282
+ const rel = prefix ? `${prefix}/${name}` : name;
283
+ if (dirent.isSymbolicLink()) continue;
284
+ if (dirent.isDirectory()) {
285
+ if (SKIP_DIRS.has(name)) continue;
286
+ dirs.push({ name, rel });
287
+ continue;
288
+ }
289
+ if (!dirent.isFile()) continue;
290
+ plain.push({ name, rel });
291
+ }
292
+ dirs.sort((left, right) => left.name.localeCompare(right.name));
293
+ plain.sort((left, right) => left.name.localeCompare(right.name));
294
+
295
+ // 目录在前、文件在后:目录条目也占用上限名额,先占位再递归
296
+ for (const entry of dirs) {
297
+ if (files.length >= MAX_TREE_ENTRIES) {
298
+ truncated = true;
299
+ return;
300
+ }
301
+ files.push({ path: entry.rel, kind: 'dir' });
302
+ await walk(path.join(dir, entry.name), entry.rel, depth + 1);
303
+ if (truncated) return;
304
+ }
305
+
306
+ for (const entry of plain) {
307
+ if (files.length >= MAX_TREE_ENTRIES) {
308
+ truncated = true;
309
+ return;
310
+ }
311
+ let size = 0;
312
+ try {
313
+ const stat = await fs.promises.lstat(path.join(dir, entry.name));
314
+ if (!stat.isFile()) continue;
315
+ size = stat.size;
316
+ } catch {
317
+ continue;
318
+ }
319
+ totalBytes += size;
320
+ files.push({ path: entry.rel, kind: 'file', size });
321
+ }
322
+ }
323
+
324
+ await walk(root, '', 0);
325
+
326
+ const hasSkillMd = files.some((entry) => entry.kind === 'file' && entry.path === 'SKILL.md');
327
+ return {
328
+ root,
329
+ kind: 'dir',
330
+ body: hasSkillMd ? 'SKILL.md' : null,
331
+ hasSkillMd,
332
+ files,
333
+ totalBytes,
334
+ truncated,
335
+ };
336
+ }
package/lib/routes.js CHANGED
@@ -65,6 +65,9 @@ function matchRoute(method, pathname) {
65
65
  if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
66
66
  return { name: 'skill.body', params: { name: decodeURIComponent(segments[1]) } };
67
67
  }
68
+ if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'files' && method === 'GET') {
69
+ return { name: 'skill.files', params: { name: decodeURIComponent(segments[1]) } };
70
+ }
68
71
  if (segments[0] === 'skills' && segments.length === 3 && segments[2] === 'adopt' && method === 'POST') {
69
72
  return { name: 'skill.adopt', params: { name: decodeURIComponent(segments[1]) } };
70
73
  }
@@ -160,7 +163,9 @@ async function dispatch(route, { body, query, service }) {
160
163
  case 'diagnostics':
161
164
  return service.diagnostics();
162
165
  case 'skill.body':
163
- return service.listSkillBody({ name: route.params.name });
166
+ return service.listSkillBody({ name: route.params.name, path: query.path });
167
+ case 'skill.files':
168
+ return service.listSkillFiles({ name: route.params.name });
164
169
  case 'skill.adopt':
165
170
  return service.adopt({ name: route.params.name });
166
171
  case 'skill.mode':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",