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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/bin/lib/atomic-write.mjs +72 -0
  4. package/bin/lib/paths.mjs +65 -0
  5. package/bin/lib/port.mjs +23 -0
  6. package/bin/skillhelper.mjs +720 -0
  7. package/build/build-site.mjs +35 -0
  8. package/build/build-web.mjs +29 -0
  9. package/build/pack-smoke.mjs +195 -0
  10. package/build/prepare-publish.mjs +3 -0
  11. package/build/verify.mjs +157 -0
  12. package/config/sources.example.yaml +126 -0
  13. package/docs/Frontend-Spec.md +1207 -0
  14. package/docs/scan_skills_rules.md +432 -0
  15. package/package.json +95 -0
  16. package/packages/scanner/package.json +15 -0
  17. package/packages/scanner/src/adapters/claude-agents.mjs +134 -0
  18. package/packages/scanner/src/adapters/claude-plugin.mjs +260 -0
  19. package/packages/scanner/src/adapters/codex-plugin.mjs +330 -0
  20. package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
  21. package/packages/scanner/src/adapters/file-docs.mjs +158 -0
  22. package/packages/scanner/src/adapters/hermes-plugin.mjs +149 -0
  23. package/packages/scanner/src/adapters/markdown-skill.mjs +229 -0
  24. package/packages/scanner/src/adapters/mcp-config.mjs +190 -0
  25. package/packages/scanner/src/adapters/scan-tier.mjs +99 -0
  26. package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
  27. package/packages/scanner/src/adapters/tier1-editor-skills.mjs +278 -0
  28. package/packages/scanner/src/adapters/tier2-user-skills.mjs +218 -0
  29. package/packages/scanner/src/adapters/tier3-other-skills.mjs +225 -0
  30. package/packages/scanner/src/config/editor-tiers.mjs +191 -0
  31. package/packages/scanner/src/core/atomic-write.mjs +11 -0
  32. package/packages/scanner/src/core/descriptor.mjs +143 -0
  33. package/packages/scanner/src/core/path-hash.mjs +89 -0
  34. package/packages/scanner/src/core/registry.mjs +128 -0
  35. package/packages/scanner/src/hash/path-hash.mjs +8 -0
  36. package/packages/scanner/src/icon/brand-map.mjs +268 -0
  37. package/packages/scanner/src/icon/icon-extractor.mjs +466 -0
  38. package/packages/scanner/src/index.mjs +450 -0
  39. package/packages/scanner/src/types.d.ts +144 -0
  40. package/packages/scanner/src/utils.mjs +194 -0
  41. package/packages/server/package.json +15 -0
  42. package/packages/server/src/index.mjs +1071 -0
  43. package/packages/server/src/labels.mjs +64 -0
  44. package/packages/server/src/langdetect.mjs +34 -0
  45. package/packages/server/src/llm-client.mjs +42 -0
  46. package/packages/server/src/translate-cache.mjs +132 -0
  47. package/packages/server/src/translator.mjs +53 -0
  48. package/packages/web/README.md +36 -0
  49. package/packages/web/dist/assets/index-CGt04vt0.js +304 -0
  50. package/packages/web/dist/assets/index-CLSDoa3H.css +1 -0
  51. package/packages/web/dist/brand-icon.png +0 -0
  52. package/packages/web/dist/brand-logo.png +0 -0
  53. package/packages/web/dist/favicon-16x16.png +0 -0
  54. package/packages/web/dist/favicon-192x192.png +0 -0
  55. package/packages/web/dist/favicon-32x32.png +0 -0
  56. package/packages/web/dist/favicon-512x512.png +0 -0
  57. package/packages/web/dist/favicon-source.svg +5 -0
  58. package/packages/web/dist/favicon.ico +0 -0
  59. package/packages/web/dist/favicon.svg +5 -0
  60. package/packages/web/dist/icons/hermes-128.png +0 -0
  61. package/packages/web/dist/icons/hermes-192.png +0 -0
  62. package/packages/web/dist/icons/hermes-256.png +0 -0
  63. package/packages/web/dist/icons/hermes-32.png +0 -0
  64. package/packages/web/dist/icons/hermes-512.png +0 -0
  65. package/packages/web/dist/icons/hermes-64.png +0 -0
  66. package/packages/web/dist/icons/hermes.png +0 -0
  67. package/packages/web/dist/index.html +37 -0
  68. package/packages/web/dist/robots.txt +3 -0
  69. package/packages/web/dist/site.webmanifest +24 -0
  70. package/packages/web/public/brand-icon.png +0 -0
  71. package/packages/web/public/brand-logo.png +0 -0
  72. package/packages/web/public/favicon-16x16.png +0 -0
  73. package/packages/web/public/favicon-192x192.png +0 -0
  74. package/packages/web/public/favicon-32x32.png +0 -0
  75. package/packages/web/public/favicon-512x512.png +0 -0
  76. package/packages/web/public/favicon-source.svg +5 -0
  77. package/packages/web/public/favicon.ico +0 -0
  78. package/packages/web/public/favicon.svg +5 -0
  79. package/packages/web/public/icons/hermes-128.png +0 -0
  80. package/packages/web/public/icons/hermes-192.png +0 -0
  81. package/packages/web/public/icons/hermes-256.png +0 -0
  82. package/packages/web/public/icons/hermes-32.png +0 -0
  83. package/packages/web/public/icons/hermes-512.png +0 -0
  84. package/packages/web/public/icons/hermes-64.png +0 -0
  85. package/packages/web/public/icons/hermes.png +0 -0
  86. package/packages/web/public/robots.txt +3 -0
  87. package/packages/web/public/site.webmanifest +24 -0
  88. package/scripts/install-and-sync.sh +209 -0
  89. package/scripts/sync-skills.sh +605 -0
@@ -0,0 +1,229 @@
1
+ // markdown-skill adapter — shared logic for any source whose items are
2
+ // SKILL.md files with YAML frontmatter (Hermes, Claude Code, gstack, …).
3
+ //
4
+ // Usage:
5
+ // import { scanMarkdownSkills } from './adapters/markdown-skill.mjs';
6
+ // const items = await scanMarkdownSkills({
7
+ // source: 'hermes',
8
+ // roots: ['~/.hermes/skills'],
9
+ // fileGlob: 'SKILL.md',
10
+ // limits,
11
+ // });
12
+
13
+ import path from 'node:path';
14
+ import fg from 'fast-glob';
15
+ import {
16
+ expandRoots,
17
+ classifyRoot,
18
+ readFileSafe,
19
+ parseFrontmatter,
20
+ inferBrand,
21
+ inferProduct,
22
+ deriveDescription,
23
+ makePreview,
24
+ sha1Id,
25
+ } from '../utils.mjs';
26
+
27
+ /**
28
+ * @param {object} opts
29
+ * @param {string} opts.source — IR source tag
30
+ * @param {string[]} opts.roots — base dirs (with ~ / globs)
31
+ * @param {string} [opts.fileGlob='SKILL.md'] — relative glob from each root
32
+ * @param {object} [opts.limits] — { maxFiles, maxFileBytes }
33
+ * @returns {Promise<{items:import('../types').SkillItem[], stats:object}>}
34
+ */
35
+ export async function scanMarkdownSkills(opts) {
36
+ const {
37
+ source,
38
+ roots,
39
+ fileGlob = '**/SKILL.md',
40
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
41
+ } = opts;
42
+
43
+ const expanded = await expandRoots(roots);
44
+ if (!expanded.length) {
45
+ return { items: [], stats: { source, available: false, files: 0 } };
46
+ }
47
+
48
+ // collect file paths first
49
+ const files = [];
50
+ for (const root of expanded) {
51
+ const found = await fg(fileGlob, {
52
+ cwd: root,
53
+ absolute: true,
54
+ onlyFiles: true,
55
+ // gstack and similar plugins stash skills under hidden dirs like
56
+ // `.agents/skills/...`, `.cursor/skills/...`. Default dot:false dropped
57
+ // ~80% of claude-code skills. We let dotfiles in but still nuke noise
58
+ // dirs (`.git`, `node_modules`, `dist`) via the ignore list.
59
+ dot: true,
60
+ followSymbolicLinks: true,
61
+ deep: 10,
62
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
63
+ });
64
+ for (const f of found) {
65
+ files.push({ abs: f, root });
66
+ if (files.length >= limits.maxFiles) break;
67
+ }
68
+ if (files.length >= limits.maxFiles) break;
69
+ }
70
+
71
+ // parse each
72
+ const items = files.map(({ abs, root }) => parseSkillFile({ abs, root, source, limits }));
73
+ return {
74
+ items,
75
+ stats: { source, available: true, files: items.length, roots: expanded },
76
+ };
77
+ }
78
+
79
+ function parseSkillFile({ abs, root, source, limits }) {
80
+ const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
81
+ const id = sha1Id(abs);
82
+ const rel = path.relative(root, abs);
83
+
84
+ // category from the directory chain between root and the file
85
+ // e.g. ~/.hermes/skills/devops/new-api-deployment/SKILL.md
86
+ // → relDir = "devops/new-api-deployment"
87
+ // → category = "devops"
88
+ const relDir = path.dirname(rel);
89
+ const category = relDir && relDir !== '.' ? relDir.split('/')[0] : undefined;
90
+
91
+ // The skill's machine-name is the parent directory (Hermes / Claude Code
92
+ // convention is one SKILL.md per dir).
93
+ const dirName = path.basename(path.dirname(abs));
94
+
95
+ if (error) {
96
+ return baseItem({ abs, id, source, dirName, category, mtime, raw: '', parseError: error });
97
+ }
98
+ if (truncated) {
99
+ return baseItem({
100
+ abs, id, source, dirName, category, mtime, raw: '',
101
+ parseError: `file > ${limits.maxFileBytes} bytes, skipped`,
102
+ });
103
+ }
104
+
105
+ const { data: fm, body, i18n, parseError } = parseFrontmatter(text);
106
+
107
+ const name = (fm.name || dirName).toString().trim();
108
+ const title = (fm.title || fm.name || dirName).toString().trim();
109
+ const description = deriveDescription(fm, body);
110
+
111
+ // triggers — accept several field names used in the wild
112
+ const triggers = collectTriggers(fm);
113
+
114
+ // tags — accept array or comma-separated string
115
+ const tags = collectTags(fm);
116
+
117
+ // links — fm.links: [{label, url}] or fm.url: string
118
+ const links = collectLinks(fm);
119
+
120
+ const product = inferProduct({ name, category });
121
+ const item = {
122
+ id,
123
+ kind: 'skill',
124
+ source,
125
+ editor: editorForSource(source),
126
+ name,
127
+ title: title !== name ? title : undefined,
128
+ description,
129
+ category,
130
+ triggers: triggers.length ? triggers : undefined,
131
+ tags: tags.length ? tags : undefined,
132
+ paths: {
133
+ abs,
134
+ rel,
135
+ rootKind: classifyRoot(abs),
136
+ },
137
+ preview: makePreview(body),
138
+ raw: text,
139
+ links: links.length ? links : undefined,
140
+ updatedAt: new Date(mtime).toISOString(),
141
+ product,
142
+ };
143
+
144
+ // Include i18n translations if present
145
+ if (i18n) {
146
+ item.i18n = {
147
+ ...i18n,
148
+ translatedAt: new Date().toISOString(),
149
+ translationModel: 'frontmatter',
150
+ };
151
+ }
152
+
153
+ item.brand = inferBrand({
154
+ name: item.name,
155
+ description: item.description,
156
+ category: item.category,
157
+ raw: body,
158
+ });
159
+ if (parseError) item.parseError = parseError;
160
+ return item;
161
+ }
162
+
163
+ function baseItem({ abs, id, source, dirName, category, mtime, raw, parseError }) {
164
+ return {
165
+ id,
166
+ kind: 'skill',
167
+ source,
168
+ editor: editorForSource(source),
169
+ name: dirName,
170
+ description: undefined,
171
+ category,
172
+ paths: { abs, rootKind: classifyRoot(abs) },
173
+ preview: '',
174
+ raw,
175
+ updatedAt: new Date(mtime).toISOString(),
176
+ parseError,
177
+ };
178
+ }
179
+
180
+ function editorForSource(source) {
181
+ const map = {
182
+ hermes: 'Hermes Agent',
183
+ 'claude-code': 'Claude Code',
184
+ codex: 'Codex',
185
+ cursor: 'Cursor',
186
+ 'mcp-config': 'MCP',
187
+ project: 'Project Docs',
188
+ };
189
+ return map[source] || source;
190
+ }
191
+
192
+ function collectTriggers(fm) {
193
+ const out = [];
194
+ const candidates = [fm.triggers, fm.aliases, fm.when_to_use];
195
+ for (const c of candidates) {
196
+ if (!c) continue;
197
+ if (Array.isArray(c)) {
198
+ out.push(...c.filter(x => typeof x === 'string').map(x => x.trim()));
199
+ } else if (typeof c === 'string') {
200
+ // when_to_use is usually a long sentence — keep it as a single trigger
201
+ out.push(c.trim());
202
+ }
203
+ }
204
+ // dedup, preserve order
205
+ return [...new Set(out)];
206
+ }
207
+
208
+ function collectTags(fm) {
209
+ const v = fm.tags;
210
+ if (!v) return [];
211
+ if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
212
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
213
+ return [];
214
+ }
215
+
216
+ function collectLinks(fm) {
217
+ const out = [];
218
+ if (Array.isArray(fm.links)) {
219
+ for (const l of fm.links) {
220
+ if (l && typeof l === 'object' && l.url) {
221
+ out.push({ label: l.label || l.url, url: l.url });
222
+ }
223
+ }
224
+ }
225
+ if (typeof fm.url === 'string') {
226
+ out.push({ label: 'docs', url: fm.url });
227
+ }
228
+ return out;
229
+ }
@@ -0,0 +1,190 @@
1
+ // MCP config adapter — reads local config files and exposes MCP servers as safe IR.
2
+ // Values that may contain credentials are redacted before entering `raw`.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import YAML from 'yaml';
7
+ import {
8
+ classifyRoot,
9
+ expandTilde,
10
+ makePreview,
11
+ readFileSafe,
12
+ sha1Id,
13
+ } from '../utils.mjs';
14
+
15
+ const SECRET_KEY_RE = /(token|key|secret|password|passwd|credential|authorization|auth|cookie)/i;
16
+
17
+ export async function scanMcpConfigs(opts) {
18
+ const {
19
+ source = 'mcp-config',
20
+ editor = 'MCP',
21
+ files = [],
22
+ limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
23
+ } = opts;
24
+
25
+ const items = [];
26
+ for (const f of files || []) {
27
+ const abs = path.resolve(expandTilde(f));
28
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
29
+ items.push(...parseConfigFile({ abs, source, editor, limits }));
30
+ if (items.length >= limits.maxFiles) break;
31
+ }
32
+ return { items, stats: { source, available: true, files: items.length } };
33
+ }
34
+
35
+ function parseConfigFile({ abs, source, editor, limits }) {
36
+ const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
37
+ const rootKind = classifyRoot(abs);
38
+ const fileName = path.basename(abs);
39
+ const brand = inferBrandFromPath(abs);
40
+ if (error || truncated) {
41
+ return [baseItem({ abs, source, editor, brand, name: fileName, description: error || 'file too large', raw: '', mtime, rootKind })];
42
+ }
43
+
44
+ const parsed = parseAny(abs, text);
45
+ if (!parsed.ok) {
46
+ return [baseItem({ abs, source, editor, brand, name: fileName, description: `MCP/config file: ${fileName}`, raw: redactText(text), mtime, rootKind, parseError: parsed.error })];
47
+ }
48
+
49
+ const servers = extractMcpServers(parsed.value);
50
+ if (!servers.length) {
51
+ return [baseItem({ abs, source, editor, brand, name: fileName, description: `Config file: ${fileName}`, raw: safeStringify(parsed.value), mtime, rootKind })];
52
+ }
53
+
54
+ return servers.map(([serverName, cfg]) => {
55
+ const safeCfg = redact(cfg);
56
+ const command = typeof cfg?.command === 'string' ? cfg.command : undefined;
57
+ const url = typeof cfg?.url === 'string' ? redactText(cfg.url) : undefined;
58
+ const description = command
59
+ ? `MCP server · command: ${command}`
60
+ : url
61
+ ? `MCP server · url: ${url}`
62
+ : 'MCP server';
63
+ const item = {
64
+ id: sha1Id(`${abs}#${serverName}`),
65
+ kind: 'mcp',
66
+ source,
67
+ editor,
68
+ name: serverName,
69
+ description,
70
+ category: 'mcp',
71
+ product: serverName,
72
+ tags: ['mcp'],
73
+ paths: { abs, rel: fileName, rootKind },
74
+ preview: makePreview(safeStringify(safeCfg), 600),
75
+ raw: safeStringify({ [serverName]: safeCfg }),
76
+ updatedAt: new Date(mtime).toISOString(),
77
+ };
78
+ if (brand) {
79
+ item.brand = brand;
80
+ item.editorBrand = brand;
81
+ }
82
+ return item;
83
+ });
84
+ }
85
+
86
+ function inferBrandFromPath(abs) {
87
+ const normalized = abs.toLowerCase();
88
+ if (normalized.includes('/.codex/') || normalized.includes('codex')) return 'codex';
89
+ if (normalized.includes('/.claude/') || normalized.includes('claude_desktop') || normalized.includes('/claude')) return 'claude';
90
+ if (normalized.includes('/.hermes/') || normalized.includes('/hermes/')) return 'hermes';
91
+ if (normalized.includes('/.cursor/') || normalized.includes('cursor')) return 'cursor';
92
+ return undefined;
93
+ }
94
+
95
+ function baseItem({ abs, source, editor, brand, name, description, raw, mtime, rootKind, parseError }) {
96
+ const item = {
97
+ id: sha1Id(abs),
98
+ kind: 'config',
99
+ source,
100
+ editor,
101
+ name,
102
+ description,
103
+ category: 'config',
104
+ product: name,
105
+ paths: { abs, rel: path.basename(abs), rootKind },
106
+ preview: makePreview(raw, 600),
107
+ raw,
108
+ updatedAt: new Date(mtime).toISOString(),
109
+ };
110
+ if (brand) {
111
+ item.brand = brand;
112
+ item.editorBrand = brand;
113
+ }
114
+ if (parseError) item.parseError = parseError;
115
+ return item;
116
+ }
117
+
118
+ function parseAny(abs, text) {
119
+ try {
120
+ if (/\.json$/i.test(abs)) return { ok: true, value: JSON.parse(text) };
121
+ if (/\.ya?ml$/i.test(abs)) return { ok: true, value: YAML.parse(text) || {} };
122
+ if (/\.toml$/i.test(abs)) return { ok: true, value: parseTomlMcpSubset(text) };
123
+ return { ok: true, value: YAML.parse(text) || {} };
124
+ } catch (e) {
125
+ return { ok: false, error: e.message };
126
+ }
127
+ }
128
+
129
+ function extractMcpServers(obj) {
130
+ if (!obj || typeof obj !== 'object') return [];
131
+ const candidate = obj.mcpServers || obj.mcp_servers || obj.servers || obj.mcp?.servers;
132
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
133
+ return Object.entries(candidate);
134
+ }
135
+
136
+ function parseTomlMcpSubset(text) {
137
+ const out = { mcp_servers: {} };
138
+ let current = null;
139
+ for (const rawLine of text.split(/\r?\n/)) {
140
+ const line = rawLine.trim();
141
+ if (!line || line.startsWith('#')) continue;
142
+ const sec = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
143
+ if (sec) {
144
+ current = sec[1].replace(/^['"]|['"]$/g, '');
145
+ out.mcp_servers[current] = {};
146
+ continue;
147
+ }
148
+ if (!current) continue;
149
+ const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
150
+ if (!kv) continue;
151
+ const key = kv[1];
152
+ out.mcp_servers[current][key] = parseTomlValue(kv[2]);
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function parseTomlValue(v) {
158
+ const s = v.trim();
159
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1);
160
+ if (s.startsWith('[') && s.endsWith(']')) {
161
+ return s.slice(1, -1).split(',').map(x => parseTomlValue(x.trim())).filter(x => x !== '');
162
+ }
163
+ if (s === 'true') return true;
164
+ if (s === 'false') return false;
165
+ return s;
166
+ }
167
+
168
+ function redact(value, key = '') {
169
+ if (SECRET_KEY_RE.test(key)) return '[REDACTED]';
170
+ if (Array.isArray(value)) return value.map(v => redact(v, key));
171
+ if (value && typeof value === 'object') {
172
+ const out = {};
173
+ for (const [k, v] of Object.entries(value)) out[k] = redact(v, k);
174
+ return out;
175
+ }
176
+ if (typeof value === 'string') return redactText(value);
177
+ return value;
178
+ }
179
+
180
+ function redactText(text) {
181
+ return String(text || '')
182
+ .replace(/(sk-[A-Za-z0-9_-]{12,})/g, '[REDACTED]')
183
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1[REDACTED]')
184
+ .replace(/([?&](?:token|key|secret|password|auth)=)[^&\s]+/gi, '$1[REDACTED]')
185
+ .replace(/((?:TOKEN|KEY|SECRET|PASSWORD|AUTH)[A-Z0-9_]*\s*[=:]\s*)[^\s"']+/g, '$1[REDACTED]');
186
+ }
187
+
188
+ function safeStringify(obj) {
189
+ return JSON.stringify(redact(obj), null, 2);
190
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @file scan-tier.mjs
3
+ * v4.0 三层优先级扫描器
4
+ * 集成 Tier 1(编辑器工具)+ Tier 2(用户根目录)+ Tier 3(其他)
5
+ */
6
+
7
+ import { scanTier1EditorSkills } from './tier1-editor-skills.mjs';
8
+ import { scanTier2UserSkills } from './tier2-user-skills.mjs';
9
+ import { scanTier3OtherSkills } from './tier3-other-skills.mjs';
10
+ import { pathHashCache } from '../hash/path-hash.mjs';
11
+
12
+ /**
13
+ * 三层优先级扫描
14
+ *
15
+ * @param {Object} options
16
+ * @param {boolean} [options.scanTier1=true] - 是否扫描第1层(编辑器工具)
17
+ * @param {boolean} [options.scanTier2=true] - 是否扫描第2层(用户根目录)
18
+ * @param {boolean} [options.scanTier3=false] - 是否扫描第3层(其他位置)
19
+ * @param {string} [options.projectRoot] - 项目根目录(用于扫描 .hermes/skills 等)
20
+ * @param {Object} [options.limits] - { maxFiles, maxFileBytes }
21
+ * @returns {Promise<{items: SkillItem[], stats: Object}>}
22
+ */
23
+ export async function scanTierSkills(options = {}) {
24
+ const {
25
+ scanTier1 = true,
26
+ scanTier2 = true,
27
+ scanTier3 = false,
28
+ projectRoot = process.cwd(),
29
+ limits = {}
30
+ } = options;
31
+
32
+ const mergedLimits = {
33
+ maxFiles: limits.maxFiles ?? 5000,
34
+ maxFileBytes: limits.maxFileBytes ?? 1024 * 1024,
35
+ };
36
+
37
+ // Step 1: 扫描第1层
38
+ console.error('[scan] Tier 1: scanning editor tools...');
39
+ const tier1Result = await scanTier1EditorSkills({ projectRoot, limits: mergedLimits });
40
+ const tier1Items = tier1Result.items;
41
+ const tier1PathHashes = tier1Result.pathHashes;
42
+ console.error(`[scan] Tier 1: found ${tier1Items.length} skills`);
43
+
44
+ // Step 2: 扫描第2层(排除第1层已有的)
45
+ console.error('[scan] Tier 2: scanning user skills library...');
46
+ const tier2Result = await scanTier2UserSkills({
47
+ tier1PathHashes,
48
+ limits: mergedLimits,
49
+ });
50
+ const tier2Items = tier2Result.items;
51
+ const tier2PathHashes = tier2Result.pathHashes;
52
+ console.error(`[scan] Tier 2: found ${tier2Items.length} skills`);
53
+
54
+ // Step 3: 扫描第3层(如果启用,排除第1、2层已有的)
55
+ let tier3Items = [];
56
+ if (scanTier3) {
57
+ console.error('[scan] Tier 3: scanning other locations...');
58
+ const tier3Result = await scanTier3OtherSkills({
59
+ tier1PathHashes,
60
+ tier2PathHashes,
61
+ limits: mergedLimits,
62
+ });
63
+ tier3Items = tier3Result.items;
64
+ console.error(`[scan] Tier 3: found ${tier3Items.length} skills`);
65
+ }
66
+
67
+ // 合并结果
68
+ const allItems = [...tier1Items, ...tier2Items, ...tier3Items];
69
+
70
+ // 按 tier → category → name 排序
71
+ allItems.sort((a, b) => {
72
+ const tierOrder = { 'tier-1': 0, 'tier-2': 1, 'tier-3': 2 };
73
+ const tierCmp = (tierOrder[a.tierId] ?? 99) - (tierOrder[b.tierId] ?? 99);
74
+ if (tierCmp !== 0) return tierCmp;
75
+
76
+ const catCmp = (a.category || '').localeCompare(b.category || '');
77
+ if (catCmp !== 0) return catCmp;
78
+
79
+ return a.name.localeCompare(b.name);
80
+ });
81
+
82
+ return {
83
+ items: allItems,
84
+ stats: {
85
+ tier1Count: tier1Items.length,
86
+ tier2Count: tier2Items.length,
87
+ tier3Count: tier3Items.length,
88
+ total: allItems.length,
89
+ pathHashes: {
90
+ tier1: Array.from(tier1PathHashes),
91
+ tier2: Array.from(tier2PathHashes),
92
+ },
93
+ cacheSize: pathHashCache.size,
94
+ scannedAt: new Date().toISOString(),
95
+ },
96
+ };
97
+ }
98
+
99
+ export { pathHashCache };