skillhelper 0.4.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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bin/lib/atomic-write.mjs +72 -0
- package/bin/lib/paths.mjs +65 -0
- package/bin/lib/port.mjs +23 -0
- package/bin/skillhelper.mjs +720 -0
- package/build/build-site.mjs +35 -0
- package/build/build-web.mjs +29 -0
- package/build/pack-smoke.mjs +195 -0
- package/build/prepare-publish.mjs +3 -0
- package/build/verify.mjs +157 -0
- package/config/sources.example.yaml +126 -0
- package/docs/Frontend-Spec.md +1207 -0
- package/docs/scan_skills_rules.md +432 -0
- package/package.json +95 -0
- package/packages/scanner/package.json +15 -0
- package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
- package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
- package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/file-docs.mjs +158 -0
- package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
- package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
- package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
- package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
- package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
- package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
- package/packages/scanner/src/config/editor-tiers.mjs +191 -0
- package/packages/scanner/src/core/atomic-write.mjs +11 -0
- package/packages/scanner/src/core/descriptor.mjs +143 -0
- package/packages/scanner/src/core/path-hash.mjs +89 -0
- package/packages/scanner/src/core/registry.mjs +128 -0
- package/packages/scanner/src/hash/path-hash.mjs +8 -0
- package/packages/scanner/src/icon/brand-map.mjs +268 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
- package/packages/scanner/src/index.mjs +450 -0
- package/packages/scanner/src/types.d.ts +144 -0
- package/packages/scanner/src/utils.mjs +194 -0
- package/packages/server/package.json +15 -0
- package/packages/server/src/index.mjs +1071 -0
- package/packages/server/src/labels.mjs +64 -0
- package/packages/server/src/langdetect.mjs +34 -0
- package/packages/server/src/llm-client.mjs +42 -0
- package/packages/server/src/translate-cache.mjs +132 -0
- package/packages/server/src/translator.mjs +53 -0
- package/packages/web/README.md +36 -0
- package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
- package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
- package/packages/web/dist/brand-icon.png +0 -0
- package/packages/web/dist/brand-logo.png +0 -0
- package/packages/web/dist/favicon-16x16.png +0 -0
- package/packages/web/dist/favicon-192x192.png +0 -0
- package/packages/web/dist/favicon-32x32.png +0 -0
- package/packages/web/dist/favicon-512x512.png +0 -0
- package/packages/web/dist/favicon-source.svg +5 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/favicon.svg +5 -0
- package/packages/web/dist/icons/hermes-128.png +0 -0
- package/packages/web/dist/icons/hermes-192.png +0 -0
- package/packages/web/dist/icons/hermes-256.png +0 -0
- package/packages/web/dist/icons/hermes-32.png +0 -0
- package/packages/web/dist/icons/hermes-512.png +0 -0
- package/packages/web/dist/icons/hermes-64.png +0 -0
- package/packages/web/dist/icons/hermes.png +0 -0
- package/packages/web/dist/index.html +37 -0
- package/packages/web/dist/robots.txt +3 -0
- package/packages/web/dist/site.webmanifest +24 -0
- package/packages/web/public/brand-icon.png +0 -0
- package/packages/web/public/brand-logo.png +0 -0
- package/packages/web/public/favicon-16x16.png +0 -0
- package/packages/web/public/favicon-192x192.png +0 -0
- package/packages/web/public/favicon-32x32.png +0 -0
- package/packages/web/public/favicon-512x512.png +0 -0
- package/packages/web/public/favicon-source.svg +5 -0
- package/packages/web/public/favicon.ico +0 -0
- package/packages/web/public/favicon.svg +5 -0
- package/packages/web/public/icons/hermes-128.png +0 -0
- package/packages/web/public/icons/hermes-192.png +0 -0
- package/packages/web/public/icons/hermes-256.png +0 -0
- package/packages/web/public/icons/hermes-32.png +0 -0
- package/packages/web/public/icons/hermes-512.png +0 -0
- package/packages/web/public/icons/hermes-64.png +0 -0
- package/packages/web/public/icons/hermes.png +0 -0
- package/packages/web/public/robots.txt +3 -0
- package/packages/web/public/site.webmanifest +24 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/sync-skills.sh +605 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// skill-adapter.mjs — adapter for scanning generic SKILL.md files from other sources
|
|
2
|
+
// Unlike markdown-skill.mjs which targets specific tools (hermes, claude-code),
|
|
3
|
+
// this adapter provides a generic way to scan SKILL.md files from any source
|
|
4
|
+
// and returns a standardized format with frontmatter parsing.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fsp from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import fg from 'fast-glob';
|
|
12
|
+
import {
|
|
13
|
+
expandRoots,
|
|
14
|
+
classifyRoot,
|
|
15
|
+
parseFrontmatter,
|
|
16
|
+
inferBrand,
|
|
17
|
+
inferProduct,
|
|
18
|
+
deriveDescription,
|
|
19
|
+
makePreview,
|
|
20
|
+
sha1Id,
|
|
21
|
+
} from '../utils.mjs';
|
|
22
|
+
|
|
23
|
+
const execFileP = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
/** Default concurrency for file reads — bounded by CPU count (R7.2). */
|
|
26
|
+
const DEFAULT_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus()?.length || 4)));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run an async mapper over items with a bounded concurrency pool.
|
|
30
|
+
* Keeps the event loop responsive and caps open file handles during scans.
|
|
31
|
+
* @template T,R
|
|
32
|
+
* @param {T[]} items
|
|
33
|
+
* @param {number} concurrency
|
|
34
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
35
|
+
* @returns {Promise<R[]>}
|
|
36
|
+
*/
|
|
37
|
+
async function mapWithConcurrency(items, concurrency, fn) {
|
|
38
|
+
const results = new Array(items.length);
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
const workers = new Array(Math.min(concurrency, items.length || 1)).fill(0).map(async () => {
|
|
41
|
+
while (cursor < items.length) {
|
|
42
|
+
const i = cursor++;
|
|
43
|
+
results[i] = await fn(items[i], i);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
await Promise.all(workers);
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Scan SKILL.md files from arbitrary locations/sources
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @param {string} opts.source — IR source tag (e.g., 'other-skills', 'custom-skills')
|
|
54
|
+
* @param {string[]} opts.roots — base dirs to scan (with ~ / globs)
|
|
55
|
+
* @param {string} [opts.fileGlob='SKILL.md'] — relative glob pattern
|
|
56
|
+
* @param {object} [opts.limits] — { maxFiles, maxFileBytes }
|
|
57
|
+
* @param {'mini'|'full'} [opts.stage='full'] — 'mini' skips heavy fields for fast first paint (R7.1)
|
|
58
|
+
* @param {number} [opts.concurrency] — parallel file reads (R7.2)
|
|
59
|
+
* @param {boolean} [opts.useSpotlight=false] — use `mdls` for timestamps (R7.3)
|
|
60
|
+
* @returns {Promise<{items:SkillItem[], stats:object}>}
|
|
61
|
+
*/
|
|
62
|
+
export async function scanSkills(opts) {
|
|
63
|
+
const {
|
|
64
|
+
source = 'other-skills',
|
|
65
|
+
roots = [],
|
|
66
|
+
fileGlob = '**/SKILL.md',
|
|
67
|
+
limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
|
|
68
|
+
stage = 'full',
|
|
69
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
70
|
+
useSpotlight = false,
|
|
71
|
+
} = opts;
|
|
72
|
+
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const expanded = await expandRoots(roots);
|
|
75
|
+
if (!expanded.length) {
|
|
76
|
+
return { items: [], stats: { source, available: false, files: 0, stage } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Collect file paths
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const root of expanded) {
|
|
82
|
+
const found = await fg(fileGlob, {
|
|
83
|
+
cwd: root,
|
|
84
|
+
absolute: true,
|
|
85
|
+
onlyFiles: true,
|
|
86
|
+
dot: true,
|
|
87
|
+
followSymbolicLinks: true,
|
|
88
|
+
deep: 10,
|
|
89
|
+
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
|
|
90
|
+
});
|
|
91
|
+
for (const f of found) {
|
|
92
|
+
files.push({ abs: f, root });
|
|
93
|
+
if (files.length >= limits.maxFiles) break;
|
|
94
|
+
}
|
|
95
|
+
if (files.length >= limits.maxFiles) break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Optional Spotlight timestamps (R7.3) — batched, best-effort.
|
|
99
|
+
const spotlightDates = useSpotlight ? await fetchSpotlightDates(files.map(f => f.abs)) : null;
|
|
100
|
+
|
|
101
|
+
// Parse each file concurrently (R7.2)
|
|
102
|
+
const items = await mapWithConcurrency(files, concurrency, ({ abs, root }) =>
|
|
103
|
+
parseSkillFile({ abs, root, source, limits, stage, spotlightDates }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Stable ordering by path (R3) — concurrency must not change output order.
|
|
107
|
+
items.sort((a, b) => (a.paths?.abs || '').localeCompare(b.paths?.abs || ''));
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
items,
|
|
111
|
+
stats: {
|
|
112
|
+
source,
|
|
113
|
+
available: true,
|
|
114
|
+
files: items.length,
|
|
115
|
+
roots: expanded,
|
|
116
|
+
stage,
|
|
117
|
+
duration: Date.now() - startedAt,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Best-effort batched Spotlight timestamps via `mdls` (R7.3, opt-in).
|
|
124
|
+
* Returns a Map<abs, {creationDate?, contentChangeDate?}> or null on failure.
|
|
125
|
+
* @param {string[]} paths
|
|
126
|
+
* @returns {Promise<Map<string, {creationDate?: string, contentChangeDate?: string}>|null>}
|
|
127
|
+
*/
|
|
128
|
+
async function fetchSpotlightDates(paths) {
|
|
129
|
+
if (process.platform !== 'darwin' || !paths.length) return null;
|
|
130
|
+
const map = new Map();
|
|
131
|
+
const CHUNK = 50;
|
|
132
|
+
try {
|
|
133
|
+
for (let i = 0; i < paths.length; i += CHUNK) {
|
|
134
|
+
const chunk = paths.slice(i, i + CHUNK);
|
|
135
|
+
const { stdout } = await execFileP('mdls', [
|
|
136
|
+
'-name', 'kMDItemFSCreationDate',
|
|
137
|
+
'-name', 'kMDItemFSContentChangeDate',
|
|
138
|
+
'-raw',
|
|
139
|
+
...chunk,
|
|
140
|
+
]);
|
|
141
|
+
// -raw emits values separated by NUL, in order: [creation, change] per file.
|
|
142
|
+
const values = stdout.split('\0');
|
|
143
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
144
|
+
const creation = values[j * 2];
|
|
145
|
+
const change = values[j * 2 + 1];
|
|
146
|
+
const entry = {};
|
|
147
|
+
if (creation && creation !== '(null)') entry.creationDate = toIso(creation);
|
|
148
|
+
if (change && change !== '(null)') entry.contentChangeDate = toIso(change);
|
|
149
|
+
if (entry.creationDate || entry.contentChangeDate) map.set(chunk[j], entry);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return map;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toIso(mdlsDate) {
|
|
159
|
+
// mdls raw date looks like "2026-07-01 10:30:00 +0000"
|
|
160
|
+
const d = new Date(mdlsDate.replace(' +', '+').replace(/ (\d{4})$/, ' +$1'));
|
|
161
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read a file safely with async I/O + byte limit (R7.2).
|
|
166
|
+
* @returns {Promise<{text:string, truncated:boolean, mtime:Date, error?:string}>}
|
|
167
|
+
*/
|
|
168
|
+
async function readFileSafeAsync(abs, maxBytes) {
|
|
169
|
+
try {
|
|
170
|
+
const stat = await fsp.stat(abs);
|
|
171
|
+
if (stat.size > maxBytes) {
|
|
172
|
+
return { text: '', truncated: true, mtime: stat.mtime };
|
|
173
|
+
}
|
|
174
|
+
return { text: await fsp.readFile(abs, 'utf8'), truncated: false, mtime: stat.mtime };
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { text: '', truncated: false, mtime: new Date(0), error: e.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Parse a single SKILL.md file
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {string} opts.abs — absolute path to the SKILL.md file
|
|
184
|
+
* @param {string} opts.root — root directory for relative path calculation
|
|
185
|
+
* @param {string} opts.source — source identifier
|
|
186
|
+
* @param {object} opts.limits — { maxFileBytes }
|
|
187
|
+
* @param {'mini'|'full'} opts.stage — 'mini' skips heavy fields (R7.1)
|
|
188
|
+
* @param {Map|null} opts.spotlightDates — optional Spotlight timestamps (R7.3)
|
|
189
|
+
* @returns {Promise<SkillItem>}
|
|
190
|
+
*/
|
|
191
|
+
async function parseSkillFile({ abs, root, source, limits, stage = 'full', spotlightDates = null }) {
|
|
192
|
+
const { text, truncated, mtime, error } = await readFileSafeAsync(abs, limits.maxFileBytes);
|
|
193
|
+
const id = sha1Id(abs);
|
|
194
|
+
const rel = path.relative(root, abs);
|
|
195
|
+
|
|
196
|
+
// Extract category from directory structure
|
|
197
|
+
const relDir = path.dirname(rel);
|
|
198
|
+
const category = relDir && relDir !== '.' ? relDir.split('/')[0] : undefined;
|
|
199
|
+
|
|
200
|
+
// Extract skill name from parent directory
|
|
201
|
+
const dirName = path.basename(path.dirname(abs));
|
|
202
|
+
|
|
203
|
+
const updatedAt =
|
|
204
|
+
spotlightDates?.get(abs)?.contentChangeDate || new Date(mtime).toISOString();
|
|
205
|
+
|
|
206
|
+
// Handle file read errors
|
|
207
|
+
if (error) {
|
|
208
|
+
return createBaseItem({ abs, id, source, dirName, category, updatedAt, raw: '', parseError: error });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Handle truncated files
|
|
212
|
+
if (truncated) {
|
|
213
|
+
return createBaseItem({
|
|
214
|
+
abs,
|
|
215
|
+
id,
|
|
216
|
+
source,
|
|
217
|
+
dirName,
|
|
218
|
+
category,
|
|
219
|
+
updatedAt,
|
|
220
|
+
raw: '',
|
|
221
|
+
parseError: `file > ${limits.maxFileBytes} bytes, skipped`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Parse frontmatter
|
|
226
|
+
const { data: fm, body, i18n, parseError } = parseFrontmatter(text);
|
|
227
|
+
|
|
228
|
+
const name = (fm.name || dirName).toString().trim();
|
|
229
|
+
const title = (fm.title || fm.name || dirName).toString().trim();
|
|
230
|
+
const description = deriveDescription(fm, body);
|
|
231
|
+
|
|
232
|
+
// Explicit icon override from frontmatter (e.g. "emoji:🤖", "url:...", "app:bundleId")
|
|
233
|
+
const icon = typeof fm.icon === 'string' ? fm.icon.trim() : undefined;
|
|
234
|
+
|
|
235
|
+
// Build the item — 'mini' stage omits heavy fields for a fast first paint (R7.1).
|
|
236
|
+
const item = {
|
|
237
|
+
id,
|
|
238
|
+
kind: 'skill',
|
|
239
|
+
source,
|
|
240
|
+
editor: 'Other Skills',
|
|
241
|
+
name,
|
|
242
|
+
title: title !== name ? title : undefined,
|
|
243
|
+
description,
|
|
244
|
+
category,
|
|
245
|
+
icon,
|
|
246
|
+
paths: {
|
|
247
|
+
abs,
|
|
248
|
+
rel,
|
|
249
|
+
rootKind: classifyRoot(abs),
|
|
250
|
+
},
|
|
251
|
+
updatedAt,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Infer brand (needed by both stages for icon resolution)
|
|
255
|
+
item.brand = inferBrand({ name, description, category, raw: stage === 'mini' ? description : body });
|
|
256
|
+
|
|
257
|
+
if (stage !== 'mini') {
|
|
258
|
+
const triggers = collectTriggers(fm);
|
|
259
|
+
const tags = collectTags(fm);
|
|
260
|
+
const links = collectLinks(fm);
|
|
261
|
+
item.triggers = triggers.length ? triggers : undefined;
|
|
262
|
+
item.tags = tags.length ? tags : undefined;
|
|
263
|
+
item.preview = makePreview(body);
|
|
264
|
+
item.raw = text;
|
|
265
|
+
item.links = links.length ? links : undefined;
|
|
266
|
+
item.product = inferProduct({ name, category });
|
|
267
|
+
|
|
268
|
+
if (i18n) {
|
|
269
|
+
item.i18n = {
|
|
270
|
+
...i18n,
|
|
271
|
+
translatedAt: new Date().toISOString(),
|
|
272
|
+
translationModel: 'frontmatter',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (parseError) item.parseError = parseError;
|
|
278
|
+
return item;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Create a minimal skill item for error cases
|
|
283
|
+
*/
|
|
284
|
+
function createBaseItem({ abs, id, source, dirName, category, updatedAt, raw, parseError }) {
|
|
285
|
+
return {
|
|
286
|
+
id,
|
|
287
|
+
kind: 'skill',
|
|
288
|
+
source,
|
|
289
|
+
editor: 'Other Skills',
|
|
290
|
+
name: dirName,
|
|
291
|
+
description: undefined,
|
|
292
|
+
category,
|
|
293
|
+
paths: { abs, rootKind: classifyRoot(abs) },
|
|
294
|
+
preview: '',
|
|
295
|
+
raw,
|
|
296
|
+
updatedAt,
|
|
297
|
+
parseError,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Collect trigger strings from frontmatter
|
|
303
|
+
*/
|
|
304
|
+
function collectTriggers(fm) {
|
|
305
|
+
const out = [];
|
|
306
|
+
const candidates = [fm.triggers, fm.aliases, fm.when_to_use];
|
|
307
|
+
for (const c of candidates) {
|
|
308
|
+
if (!c) continue;
|
|
309
|
+
if (Array.isArray(c)) {
|
|
310
|
+
out.push(...c.filter(x => typeof x === 'string').map(x => x.trim()));
|
|
311
|
+
} else if (typeof c === 'string') {
|
|
312
|
+
out.push(c.trim());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return [...new Set(out)];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Collect tags from frontmatter
|
|
320
|
+
*/
|
|
321
|
+
function collectTags(fm) {
|
|
322
|
+
const v = fm.tags;
|
|
323
|
+
if (!v) return [];
|
|
324
|
+
if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
|
|
325
|
+
if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Collect links from frontmatter
|
|
331
|
+
*/
|
|
332
|
+
function collectLinks(fm) {
|
|
333
|
+
const out = [];
|
|
334
|
+
if (Array.isArray(fm.links)) {
|
|
335
|
+
for (const l of fm.links) {
|
|
336
|
+
if (l && typeof l === 'object' && l.url) {
|
|
337
|
+
out.push({ label: l.label || l.url, url: l.url });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (typeof fm.url === 'string') {
|
|
342
|
+
out.push({ label: 'docs', url: fm.url });
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file tier1-editor-skills.mjs
|
|
3
|
+
* 扫描第1层:编辑器工具的全局和项目技能目录
|
|
4
|
+
* 支持 22 个编辑器,大小写不敏感的 skills 目录
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import fg from 'fast-glob';
|
|
10
|
+
import { expandTilde, readFileSafe, parseFrontmatter, sha1Id } from '../utils.mjs';
|
|
11
|
+
import { EDITOR_TIER_1_CONFIGS } from '../config/editor-tiers.mjs';
|
|
12
|
+
import { getPathHash, pathHashCache } from '../hash/path-hash.mjs';
|
|
13
|
+
import { ensureRegistered, getDescriptor } from '../core/registry.mjs';
|
|
14
|
+
import { computeConfidence } from '../core/descriptor.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 扫描第1层:编辑器工具的全局和项目技能目录
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {string} [options.projectRoot] - 项目根目录(用于扫描 .hermes/skills 等)
|
|
21
|
+
* @param {Object} [options.limits] - { maxFiles, maxFileBytes }
|
|
22
|
+
* @returns {Promise<{items: SkillItem[], pathHashes: Set<string>}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function scanTier1EditorSkills(options = {}) {
|
|
25
|
+
const { projectRoot, limits = {} } = options;
|
|
26
|
+
const mergedLimits = {
|
|
27
|
+
maxFiles: limits.maxFiles ?? 5000,
|
|
28
|
+
maxFileBytes: limits.maxFileBytes ?? 1024 * 1024,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// 确保 descriptor registry 已初始化(渐进迁移:registry 与现有配置并存)
|
|
32
|
+
ensureRegistered();
|
|
33
|
+
|
|
34
|
+
const items = [];
|
|
35
|
+
const pathHashes = new Set();
|
|
36
|
+
let fileCount = 0;
|
|
37
|
+
|
|
38
|
+
for (const editor of EDITOR_TIER_1_CONFIGS) {
|
|
39
|
+
if (fileCount >= mergedLimits.maxFiles) break;
|
|
40
|
+
|
|
41
|
+
const desc = getDescriptor(`tier1:${editor.brand}`);
|
|
42
|
+
|
|
43
|
+
// 尝试全局路径
|
|
44
|
+
const globalPath = expandTilde(editor.globalPath);
|
|
45
|
+
const globalResult = await scanEditorDirectory(
|
|
46
|
+
globalPath,
|
|
47
|
+
editor,
|
|
48
|
+
desc,
|
|
49
|
+
mergedLimits,
|
|
50
|
+
mergedLimits.maxFiles - fileCount
|
|
51
|
+
);
|
|
52
|
+
items.push(...globalResult.items);
|
|
53
|
+
fileCount += globalResult.items.length;
|
|
54
|
+
globalResult.items.forEach(item => {
|
|
55
|
+
const hash = pathHashCache.getOrCompute(item.paths.abs);
|
|
56
|
+
pathHashes.add(hash);
|
|
57
|
+
item.pathHash = hash;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (fileCount >= mergedLimits.maxFiles) break;
|
|
61
|
+
|
|
62
|
+
// 尝试项目路径
|
|
63
|
+
if (projectRoot) {
|
|
64
|
+
const projectPath = path.join(projectRoot, editor.projectPath);
|
|
65
|
+
const projectResult = await scanEditorDirectory(
|
|
66
|
+
projectPath,
|
|
67
|
+
editor,
|
|
68
|
+
desc,
|
|
69
|
+
mergedLimits,
|
|
70
|
+
mergedLimits.maxFiles - fileCount
|
|
71
|
+
);
|
|
72
|
+
items.push(...projectResult.items);
|
|
73
|
+
fileCount += projectResult.items.length;
|
|
74
|
+
projectResult.items.forEach(item => {
|
|
75
|
+
const hash = pathHashCache.getOrCompute(item.paths.abs);
|
|
76
|
+
pathHashes.add(hash);
|
|
77
|
+
item.pathHash = hash;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { items, pathHashes };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 扫描单个编辑器目录
|
|
87
|
+
* 支持大小写不敏感的 skills 目录名和 SKILL.md / skill.md 文件
|
|
88
|
+
* glob 模式 / ignore / deep 从 descriptor 读取(registry 化),desc 为 null 时回退原值
|
|
89
|
+
*
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
async function scanEditorDirectory(basePath, editor, desc, limits, maxRemaining) {
|
|
93
|
+
const items = [];
|
|
94
|
+
|
|
95
|
+
// 从 descriptor 读检测规则,回退到原硬编码值(渐进迁移期防御)
|
|
96
|
+
const relPatterns = desc?.detect?.globPatterns || [
|
|
97
|
+
`**/[sS][kK][iI][lL][lL][sS]/**/[sS][kK][iI][lL][lL].[mM][dD]`,
|
|
98
|
+
`**/SKILL.md`,
|
|
99
|
+
`**/skill.md`,
|
|
100
|
+
];
|
|
101
|
+
const ignore = desc?.detect?.ignore || ['**/node_modules/**', '**/.git/**', '**/dist/**'];
|
|
102
|
+
const deep = desc?.detect?.deep ?? 10;
|
|
103
|
+
|
|
104
|
+
// 置信度统计
|
|
105
|
+
const dirExists = safeDirExists(basePath);
|
|
106
|
+
let matchedFiles = 0;
|
|
107
|
+
let parsedValid = 0;
|
|
108
|
+
let metadataComplete = 0;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const patterns = relPatterns.map(p => `${basePath}/${p}`);
|
|
112
|
+
|
|
113
|
+
for (const pattern of patterns) {
|
|
114
|
+
if (items.length >= maxRemaining) break;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const matches = await fg(pattern, {
|
|
118
|
+
absolute: true,
|
|
119
|
+
onlyFiles: true,
|
|
120
|
+
dot: true,
|
|
121
|
+
followSymbolicLinks: true,
|
|
122
|
+
deep,
|
|
123
|
+
ignore,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const seen = new Set(items.map(i => i.paths.abs));
|
|
127
|
+
|
|
128
|
+
for (const filePath of matches) {
|
|
129
|
+
if (items.length >= maxRemaining || seen.has(filePath)) continue;
|
|
130
|
+
seen.add(filePath);
|
|
131
|
+
matchedFiles++;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const skill = parseSkillFile({
|
|
135
|
+
abs: filePath,
|
|
136
|
+
source: 'tier1-editor',
|
|
137
|
+
editor,
|
|
138
|
+
limits,
|
|
139
|
+
});
|
|
140
|
+
if (skill) {
|
|
141
|
+
skill.tierId = 'tier-1';
|
|
142
|
+
skill.editorBrand = editor.brand;
|
|
143
|
+
// 置信度统计:L3 解析有效(name 非空),L4 元数据完整(name+description)
|
|
144
|
+
if (skill.name) parsedValid++;
|
|
145
|
+
if (skill.name && skill.description) metadataComplete++;
|
|
146
|
+
items.push(skill);
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.warn(`[scanTier1] parse failed: ${filePath}`, e.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
// glob 模式失败,继续下一个
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// 目录不存在或其他错误,静默跳过
|
|
158
|
+
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 计算置信度并注入到每个 skill(可选字段,向后兼容)
|
|
162
|
+
const confidence = computeConfidence({ dirExists, matchedFiles, parsedValid, metadataComplete });
|
|
163
|
+
if (confidence) {
|
|
164
|
+
for (const item of items) item.confidence = confidence;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { items, confidence };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function safeDirExists(p) {
|
|
171
|
+
try {
|
|
172
|
+
return fs.existsSync(p);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 解析单个技能文件
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
function parseSkillFile({ abs, source, editor, limits }) {
|
|
183
|
+
const { text, error } = readFileSafe(abs, limits.maxFileBytes);
|
|
184
|
+
if (error) return null;
|
|
185
|
+
|
|
186
|
+
const id = sha1Id(abs);
|
|
187
|
+
const dirName = path.basename(path.dirname(abs));
|
|
188
|
+
|
|
189
|
+
let frontmatter = {};
|
|
190
|
+
let body = text;
|
|
191
|
+
let parseError = null;
|
|
192
|
+
|
|
193
|
+
// 简单的 frontmatter 解析
|
|
194
|
+
if (text.startsWith('---\n')) {
|
|
195
|
+
const endIdx = text.indexOf('\n---\n', 4);
|
|
196
|
+
if (endIdx > 0) {
|
|
197
|
+
const fm = text.slice(4, endIdx);
|
|
198
|
+
try {
|
|
199
|
+
frontmatter = parseYAML(fm);
|
|
200
|
+
body = text.slice(endIdx + 5);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
parseError = `Frontmatter parse error: ${e.message}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const name = frontmatter.name || dirName;
|
|
208
|
+
const description = frontmatter.description || '';
|
|
209
|
+
const category = frontmatter.category;
|
|
210
|
+
const brand = frontmatter.brand || editor.brand;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id,
|
|
214
|
+
kind: 'skill',
|
|
215
|
+
source,
|
|
216
|
+
name,
|
|
217
|
+
description,
|
|
218
|
+
category,
|
|
219
|
+
brand,
|
|
220
|
+
editor: editor.name,
|
|
221
|
+
paths: {
|
|
222
|
+
abs,
|
|
223
|
+
rootKind: 'home',
|
|
224
|
+
},
|
|
225
|
+
preview: makePreview(body),
|
|
226
|
+
raw: body,
|
|
227
|
+
updatedAt: new Date().toISOString(),
|
|
228
|
+
parseError,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 极简 YAML 解析(不依赖外部库)
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
function parseYAML(yamlStr) {
|
|
237
|
+
const obj = {};
|
|
238
|
+
const lines = yamlStr.split('\n');
|
|
239
|
+
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
const trimmed = line.trim();
|
|
242
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
243
|
+
|
|
244
|
+
const colonIdx = trimmed.indexOf(':');
|
|
245
|
+
if (colonIdx <= 0) continue;
|
|
246
|
+
|
|
247
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
248
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
249
|
+
|
|
250
|
+
// 去掉引号
|
|
251
|
+
const unquoted = value.startsWith('"')
|
|
252
|
+
? value.slice(1, -1)
|
|
253
|
+
: value.startsWith("'")
|
|
254
|
+
? value.slice(1, -1)
|
|
255
|
+
: value;
|
|
256
|
+
|
|
257
|
+
obj[key] = unquoted || null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return obj;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 生成预览文本
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
function makePreview(text, maxLen = 200) {
|
|
268
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
269
|
+
let preview = '';
|
|
270
|
+
for (const line of lines) {
|
|
271
|
+
if ((preview + line).length > maxLen) {
|
|
272
|
+
preview += '...';
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
preview += (preview ? ' ' : '') + line;
|
|
276
|
+
}
|
|
277
|
+
return preview;
|
|
278
|
+
}
|