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,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 };
|