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,239 @@
|
|
|
1
|
+
// directory-skill adapter — scan SKILL.md files from custom directories.
|
|
2
|
+
//
|
|
3
|
+
// This adapter scans user-specified directories (via directorySkillPaths in
|
|
4
|
+
// sources.yaml) for SKILL.md files. Each matched file is parsed as a skill
|
|
5
|
+
// and tagged with tier='directory' and dirName={parent directory name}.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// import { scanDirectorySkills } from './adapters/directory-skill.mjs';
|
|
9
|
+
// const result = await scanDirectorySkills({
|
|
10
|
+
// paths: ['~/Project/SkillsHelper/packages/skills', '~/Work/custom-skills'],
|
|
11
|
+
// limits,
|
|
12
|
+
// });
|
|
13
|
+
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fg from 'fast-glob';
|
|
16
|
+
import {
|
|
17
|
+
expandRoots,
|
|
18
|
+
classifyRoot,
|
|
19
|
+
readFileSafe,
|
|
20
|
+
parseFrontmatter,
|
|
21
|
+
inferBrand,
|
|
22
|
+
deriveDescription,
|
|
23
|
+
makePreview,
|
|
24
|
+
sha1Id,
|
|
25
|
+
} from '../utils.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scan custom directory paths for SKILL.md files.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {string[]} opts.paths - base dirs to scan (with ~ expansion)
|
|
32
|
+
* @param {string[]} [opts.globs] - optional glob patterns (default: glob matching SKILL.md)
|
|
33
|
+
* @param {object} [opts.limits] - { maxFiles, maxFileBytes }
|
|
34
|
+
* @returns {Promise}
|
|
35
|
+
*/
|
|
36
|
+
export async function scanDirectorySkills(opts) {
|
|
37
|
+
const {
|
|
38
|
+
paths = [],
|
|
39
|
+
globs = ['**/SKILL.md'],
|
|
40
|
+
limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
|
|
41
|
+
} = opts;
|
|
42
|
+
|
|
43
|
+
if (!paths || !paths.length) {
|
|
44
|
+
return { items: [], stats: { source: 'directory', available: false, files: 0 } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const expanded = await expandRoots(paths);
|
|
48
|
+
if (!expanded.length) {
|
|
49
|
+
return { items: [], stats: { source: 'directory', available: false, files: 0 } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Collect file paths from all directories and globs
|
|
53
|
+
const files = [];
|
|
54
|
+
for (const root of expanded) {
|
|
55
|
+
for (const globPattern of globs) {
|
|
56
|
+
const found = await fg(globPattern, {
|
|
57
|
+
cwd: root,
|
|
58
|
+
absolute: true,
|
|
59
|
+
onlyFiles: true,
|
|
60
|
+
dot: true,
|
|
61
|
+
followSymbolicLinks: true,
|
|
62
|
+
deep: 10,
|
|
63
|
+
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const f of found) {
|
|
67
|
+
// Avoid duplicates
|
|
68
|
+
if (!files.some(file => file.abs === f)) {
|
|
69
|
+
files.push({ abs: f, root });
|
|
70
|
+
}
|
|
71
|
+
if (files.length >= limits.maxFiles) break;
|
|
72
|
+
}
|
|
73
|
+
if (files.length >= limits.maxFiles) break;
|
|
74
|
+
}
|
|
75
|
+
if (files.length >= limits.maxFiles) break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parse each SKILL.md file
|
|
79
|
+
const items = files.map(({ abs, root }) =>
|
|
80
|
+
parseDirectorySkill({ abs, root, limits })
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
items,
|
|
85
|
+
stats: {
|
|
86
|
+
source: 'directory',
|
|
87
|
+
available: true,
|
|
88
|
+
files: items.length,
|
|
89
|
+
roots: expanded,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a single SKILL.md file from a custom directory.
|
|
96
|
+
*/
|
|
97
|
+
function parseDirectorySkill({ abs, root, limits }) {
|
|
98
|
+
const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
|
|
99
|
+
const id = sha1Id(abs);
|
|
100
|
+
const rel = path.relative(root, abs);
|
|
101
|
+
|
|
102
|
+
// Directory name: the parent directory of SKILL.md
|
|
103
|
+
// e.g. ~/Project/skills/auth-flow/SKILL.md → dirName = 'auth-flow'
|
|
104
|
+
const dirName = path.basename(path.dirname(abs));
|
|
105
|
+
|
|
106
|
+
// Category from path: if the structure is /root/category/skill-name/SKILL.md,
|
|
107
|
+
// extract category from the first level above root.
|
|
108
|
+
const relDir = path.dirname(rel);
|
|
109
|
+
const category = relDir && relDir !== '.' ? relDir.split('/')[0] : undefined;
|
|
110
|
+
|
|
111
|
+
if (error) {
|
|
112
|
+
return baseDirectoryItem({
|
|
113
|
+
abs,
|
|
114
|
+
id,
|
|
115
|
+
dirName,
|
|
116
|
+
category,
|
|
117
|
+
mtime,
|
|
118
|
+
raw: '',
|
|
119
|
+
parseError: error,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (truncated) {
|
|
124
|
+
return baseDirectoryItem({
|
|
125
|
+
abs,
|
|
126
|
+
id,
|
|
127
|
+
dirName,
|
|
128
|
+
category,
|
|
129
|
+
mtime,
|
|
130
|
+
raw: '',
|
|
131
|
+
parseError: `file > ${limits.maxFileBytes} bytes, skipped`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { data: fm, body, parseError } = parseFrontmatter(text);
|
|
136
|
+
|
|
137
|
+
const name = (fm.name || dirName).toString().trim();
|
|
138
|
+
const title = (fm.title || fm.name || dirName).toString().trim();
|
|
139
|
+
const description = deriveDescription(fm, body);
|
|
140
|
+
|
|
141
|
+
const triggers = collectTriggers(fm);
|
|
142
|
+
const tags = collectTags(fm);
|
|
143
|
+
const links = collectLinks(fm);
|
|
144
|
+
|
|
145
|
+
const item = {
|
|
146
|
+
id,
|
|
147
|
+
kind: 'skill',
|
|
148
|
+
source: 'directory',
|
|
149
|
+
editor: 'Custom Skills',
|
|
150
|
+
name,
|
|
151
|
+
title: title !== name ? title : undefined,
|
|
152
|
+
description,
|
|
153
|
+
category,
|
|
154
|
+
triggers: triggers.length ? triggers : undefined,
|
|
155
|
+
tags: tags.length ? tags : undefined,
|
|
156
|
+
paths: {
|
|
157
|
+
abs,
|
|
158
|
+
rel,
|
|
159
|
+
rootKind: classifyRoot(abs),
|
|
160
|
+
},
|
|
161
|
+
preview: makePreview(body),
|
|
162
|
+
raw: text,
|
|
163
|
+
links: links.length ? links : undefined,
|
|
164
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
165
|
+
// Tier 2: Directory-based skills
|
|
166
|
+
tier: 'directory',
|
|
167
|
+
dirName,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Brand inference from content
|
|
171
|
+
item.brand = inferBrand({
|
|
172
|
+
name: item.name,
|
|
173
|
+
description: item.description,
|
|
174
|
+
category: item.category,
|
|
175
|
+
raw: body,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (parseError) item.parseError = parseError;
|
|
179
|
+
return item;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function baseDirectoryItem({ abs, id, dirName, category, mtime, raw, parseError }) {
|
|
183
|
+
return {
|
|
184
|
+
id,
|
|
185
|
+
kind: 'skill',
|
|
186
|
+
source: 'directory',
|
|
187
|
+
editor: 'Custom Skills',
|
|
188
|
+
name: dirName,
|
|
189
|
+
description: undefined,
|
|
190
|
+
category,
|
|
191
|
+
paths: {
|
|
192
|
+
abs,
|
|
193
|
+
rootKind: classifyRoot(abs),
|
|
194
|
+
},
|
|
195
|
+
preview: '',
|
|
196
|
+
raw,
|
|
197
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
198
|
+
tier: 'directory',
|
|
199
|
+
dirName,
|
|
200
|
+
parseError,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function collectTriggers(fm) {
|
|
205
|
+
const out = [];
|
|
206
|
+
const candidates = [fm.triggers, fm.aliases, fm.when_to_use];
|
|
207
|
+
for (const c of candidates) {
|
|
208
|
+
if (!c) continue;
|
|
209
|
+
if (Array.isArray(c)) {
|
|
210
|
+
out.push(...c.filter(x => typeof x === 'string').map(x => x.trim()));
|
|
211
|
+
} else if (typeof c === 'string') {
|
|
212
|
+
out.push(c.trim());
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return [...new Set(out)];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function collectTags(fm) {
|
|
219
|
+
const v = fm.tags;
|
|
220
|
+
if (!v) return [];
|
|
221
|
+
if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
|
|
222
|
+
if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectLinks(fm) {
|
|
227
|
+
const out = [];
|
|
228
|
+
if (Array.isArray(fm.links)) {
|
|
229
|
+
for (const l of fm.links) {
|
|
230
|
+
if (l && typeof l === 'object' && l.url) {
|
|
231
|
+
out.push({ label: l.label || l.url, url: l.url });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (typeof fm.url === 'string') {
|
|
236
|
+
out.push({ label: 'docs', url: fm.url });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Generic Markdown file adapter for Codex AGENTS.md, Cursor rules, and project runbooks.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fg from 'fast-glob';
|
|
6
|
+
import {
|
|
7
|
+
classifyRoot,
|
|
8
|
+
deriveDescription,
|
|
9
|
+
expandRoots,
|
|
10
|
+
expandTilde,
|
|
11
|
+
inferBrand,
|
|
12
|
+
inferProduct,
|
|
13
|
+
makePreview,
|
|
14
|
+
parseFrontmatter,
|
|
15
|
+
readFileSafe,
|
|
16
|
+
sha1Id,
|
|
17
|
+
} from '../utils.mjs';
|
|
18
|
+
|
|
19
|
+
export async function scanFileDocs(opts) {
|
|
20
|
+
const {
|
|
21
|
+
source,
|
|
22
|
+
editor,
|
|
23
|
+
kind = 'doc',
|
|
24
|
+
files = [],
|
|
25
|
+
roots = [],
|
|
26
|
+
globs = [],
|
|
27
|
+
limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
|
|
28
|
+
} = opts;
|
|
29
|
+
|
|
30
|
+
const discovered = [];
|
|
31
|
+
for (const f of files || []) {
|
|
32
|
+
const abs = path.resolve(expandTilde(f));
|
|
33
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) discovered.push({ abs, root: path.dirname(abs) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const expandedRoots = await expandRoots(roots || []);
|
|
37
|
+
const patterns = globs?.length ? globs : [];
|
|
38
|
+
for (const root of expandedRoots) {
|
|
39
|
+
for (const pattern of patterns) {
|
|
40
|
+
const found = await fg(pattern, {
|
|
41
|
+
cwd: root,
|
|
42
|
+
absolute: true,
|
|
43
|
+
onlyFiles: true,
|
|
44
|
+
dot: true,
|
|
45
|
+
followSymbolicLinks: false,
|
|
46
|
+
deep: 8,
|
|
47
|
+
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/.venv/**', '**/vendor_imports/**'],
|
|
48
|
+
});
|
|
49
|
+
for (const abs of found) {
|
|
50
|
+
discovered.push({ abs, root });
|
|
51
|
+
if (discovered.length >= limits.maxFiles) break;
|
|
52
|
+
}
|
|
53
|
+
if (discovered.length >= limits.maxFiles) break;
|
|
54
|
+
}
|
|
55
|
+
if (discovered.length >= limits.maxFiles) break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const byAbs = new Map();
|
|
59
|
+
for (const x of discovered) if (!byAbs.has(x.abs)) byAbs.set(x.abs, x);
|
|
60
|
+
const items = [...byAbs.values()].map(x => parseDoc({ ...x, source, editor, kind, limits }));
|
|
61
|
+
return { items, stats: { source, available: true, files: items.length, roots: expandedRoots } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseDoc({ abs, root, source, editor, kind, limits }) {
|
|
65
|
+
const { text, truncated, mtime, error } = readFileSafe(abs, limits.maxFileBytes);
|
|
66
|
+
const rel = path.relative(root, abs);
|
|
67
|
+
const id = sha1Id(abs);
|
|
68
|
+
const base = path.basename(abs);
|
|
69
|
+
const project = inferProjectName(abs, root);
|
|
70
|
+
const name = inferName(abs, base, project);
|
|
71
|
+
const category = inferCategory(abs, root, base);
|
|
72
|
+
|
|
73
|
+
if (error || truncated) {
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
kind,
|
|
77
|
+
source,
|
|
78
|
+
editor,
|
|
79
|
+
name,
|
|
80
|
+
description: error || `file > ${limits.maxFileBytes} bytes, skipped`,
|
|
81
|
+
category,
|
|
82
|
+
product: project || inferProduct({ name, category }),
|
|
83
|
+
paths: { abs, rel, rootKind: classifyRoot(abs) },
|
|
84
|
+
preview: '',
|
|
85
|
+
raw: '',
|
|
86
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
87
|
+
parseError: error || 'file too large',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { data: fm, body, parseError } = parseFrontmatter(text);
|
|
92
|
+
const title = (fm.title || fm.name || name).toString().trim();
|
|
93
|
+
const description = deriveDescription(fm, body);
|
|
94
|
+
const item = {
|
|
95
|
+
id,
|
|
96
|
+
kind,
|
|
97
|
+
source,
|
|
98
|
+
editor,
|
|
99
|
+
name: title,
|
|
100
|
+
description,
|
|
101
|
+
category,
|
|
102
|
+
product: project || inferProduct({ name: title, category }),
|
|
103
|
+
tags: collectTags(fm),
|
|
104
|
+
triggers: collectTriggers(fm),
|
|
105
|
+
paths: { abs, rel, rootKind: classifyRoot(abs) },
|
|
106
|
+
preview: makePreview(body || text),
|
|
107
|
+
raw: text,
|
|
108
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
109
|
+
};
|
|
110
|
+
item.brand = inferBrand({ name: item.name, description: item.description, category: item.category, raw: body });
|
|
111
|
+
if (!item.tags?.length) delete item.tags;
|
|
112
|
+
if (!item.triggers?.length) delete item.triggers;
|
|
113
|
+
if (parseError) item.parseError = parseError;
|
|
114
|
+
return item;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function inferProjectName(abs, root) {
|
|
118
|
+
const homeProject = `${process.env.HOME}/Project/`;
|
|
119
|
+
if (abs.startsWith(homeProject)) return abs.slice(homeProject.length).split('/')[0];
|
|
120
|
+
const parts = root.split('/').filter(Boolean);
|
|
121
|
+
return parts.at(-1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function inferName(abs, base, project) {
|
|
125
|
+
if (base === 'AGENTS.md') return `${project || 'project'} / AGENTS.md`;
|
|
126
|
+
if (base === 'CLAUDE.md') return `${project || 'project'} / CLAUDE.md`;
|
|
127
|
+
if (base === '.cursorrules') return `${project || 'project'} / .cursorrules`;
|
|
128
|
+
if (base.startsWith('RUNBOOK-')) return base.replace(/\.md$/i, '');
|
|
129
|
+
return base.replace(/\.mdc?$/i, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inferCategory(abs, root, base) {
|
|
133
|
+
if (base === 'AGENTS.md') return 'codex';
|
|
134
|
+
if (base === 'CLAUDE.md') return 'claude-code';
|
|
135
|
+
if (base === '.cursorrules' || abs.includes('/.cursor/rules/')) return 'cursor';
|
|
136
|
+
if (base.startsWith('RUNBOOK-')) return 'runbook';
|
|
137
|
+
const relDir = path.dirname(path.relative(root, abs));
|
|
138
|
+
if (!relDir || relDir === '.') return 'project';
|
|
139
|
+
return relDir.split('/')[0];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectTags(fm) {
|
|
143
|
+
const v = fm.tags;
|
|
144
|
+
if (!v) return [];
|
|
145
|
+
if (Array.isArray(v)) return v.filter(x => typeof x === 'string');
|
|
146
|
+
if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function collectTriggers(fm) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const v of [fm.triggers, fm.aliases, fm.when_to_use]) {
|
|
153
|
+
if (!v) continue;
|
|
154
|
+
if (Array.isArray(v)) out.push(...v.filter(x => typeof x === 'string').map(x => x.trim()));
|
|
155
|
+
else if (typeof v === 'string') out.push(v.trim());
|
|
156
|
+
}
|
|
157
|
+
return [...new Set(out)];
|
|
158
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Hermes plugin adapter — catalogs local plugin manifests and READMEs.
|
|
2
|
+
// It never imports or executes plugin code.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fg from 'fast-glob';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import {
|
|
9
|
+
classifyRoot,
|
|
10
|
+
deriveDescription,
|
|
11
|
+
expandRoots,
|
|
12
|
+
inferBrand,
|
|
13
|
+
inferProduct,
|
|
14
|
+
makePreview,
|
|
15
|
+
readFileSafe,
|
|
16
|
+
sha1Id,
|
|
17
|
+
} from '../utils.mjs';
|
|
18
|
+
|
|
19
|
+
const MANIFEST_NAMES = ['plugin.yaml', 'plugin.yml', 'plugin.json', 'manifest.json', 'package.json'];
|
|
20
|
+
const README_NAMES = ['README.md', 'readme.md'];
|
|
21
|
+
|
|
22
|
+
export async function scanHermesPlugins(opts) {
|
|
23
|
+
const {
|
|
24
|
+
source = 'hermes-plugin',
|
|
25
|
+
editor = 'Hermes Agent',
|
|
26
|
+
roots = [],
|
|
27
|
+
limits = { maxFiles: 5000, maxFileBytes: 1024 * 1024 },
|
|
28
|
+
} = opts;
|
|
29
|
+
|
|
30
|
+
const expanded = await expandRoots(roots || []);
|
|
31
|
+
const pluginDirs = new Set();
|
|
32
|
+
for (const root of expanded) {
|
|
33
|
+
if (!fs.existsSync(root)) continue;
|
|
34
|
+
const found = await fg(['*/plugin.{yaml,yml,json}', '*/manifest.json', '*/package.json', '*/README.md', '*/readme.md'], {
|
|
35
|
+
cwd: root,
|
|
36
|
+
absolute: true,
|
|
37
|
+
onlyFiles: true,
|
|
38
|
+
dot: true,
|
|
39
|
+
followSymbolicLinks: false,
|
|
40
|
+
deep: 2,
|
|
41
|
+
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'],
|
|
42
|
+
});
|
|
43
|
+
for (const f of found) pluginDirs.add(path.dirname(f));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const items = [];
|
|
47
|
+
for (const dir of pluginDirs) {
|
|
48
|
+
items.push(parsePluginDir({ dir, source, editor, limits }));
|
|
49
|
+
if (items.length >= limits.maxFiles) break;
|
|
50
|
+
}
|
|
51
|
+
return { items, stats: { source, available: true, files: items.length, roots: expanded } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePluginDir({ dir, source, editor, limits }) {
|
|
55
|
+
const manifestPath = firstExisting(dir, MANIFEST_NAMES);
|
|
56
|
+
const readmePath = firstExisting(dir, README_NAMES);
|
|
57
|
+
const rawParts = [];
|
|
58
|
+
let manifest = {};
|
|
59
|
+
let parseError;
|
|
60
|
+
let mtime = new Date(0);
|
|
61
|
+
|
|
62
|
+
if (manifestPath) {
|
|
63
|
+
const r = readFileSafe(manifestPath, limits.maxFileBytes);
|
|
64
|
+
mtime = r.mtime || mtime;
|
|
65
|
+
if (!r.error && !r.truncated) {
|
|
66
|
+
rawParts.push(`# ${path.basename(manifestPath)}\n\n${r.text}`);
|
|
67
|
+
const parsed = parseManifest(manifestPath, r.text);
|
|
68
|
+
if (parsed.ok) manifest = parsed.value;
|
|
69
|
+
else parseError = parsed.error;
|
|
70
|
+
} else {
|
|
71
|
+
parseError = r.error || 'manifest too large';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let readmeBody = '';
|
|
76
|
+
if (readmePath) {
|
|
77
|
+
const r = readFileSafe(readmePath, limits.maxFileBytes);
|
|
78
|
+
if (r.mtime > mtime) mtime = r.mtime;
|
|
79
|
+
if (!r.error && !r.truncated) {
|
|
80
|
+
readmeBody = r.text;
|
|
81
|
+
rawParts.push(`# ${path.basename(readmePath)}\n\n${r.text}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dirName = path.basename(dir);
|
|
86
|
+
const name = String(manifest.name || manifest.title || dirName).trim();
|
|
87
|
+
const description = typeof manifest.description === 'string'
|
|
88
|
+
? manifest.description.trim()
|
|
89
|
+
: deriveDescription({}, readmeBody || rawParts.join('\n\n'));
|
|
90
|
+
const raw = rawParts.join('\n\n---\n\n');
|
|
91
|
+
const item = {
|
|
92
|
+
id: sha1Id(dir),
|
|
93
|
+
kind: 'plugin',
|
|
94
|
+
source,
|
|
95
|
+
editor,
|
|
96
|
+
name,
|
|
97
|
+
title: manifest.title && manifest.title !== name ? manifest.title : undefined,
|
|
98
|
+
description,
|
|
99
|
+
category: 'plugin',
|
|
100
|
+
product: inferProduct({ name, category: 'plugin' }),
|
|
101
|
+
tags: collectTags(manifest),
|
|
102
|
+
paths: { abs: dir, rel: dirName, rootKind: classifyRoot(dir) },
|
|
103
|
+
preview: makePreview(raw || description || '', 600),
|
|
104
|
+
raw,
|
|
105
|
+
links: collectLinks(manifest),
|
|
106
|
+
updatedAt: new Date(mtime).toISOString(),
|
|
107
|
+
};
|
|
108
|
+
item.brand = inferBrand({ name: item.name, description: item.description, category: item.category, raw });
|
|
109
|
+
if (!item.tags?.length) delete item.tags;
|
|
110
|
+
if (!item.links?.length) delete item.links;
|
|
111
|
+
if (parseError) item.parseError = parseError;
|
|
112
|
+
return item;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function firstExisting(dir, names) {
|
|
116
|
+
for (const name of names) {
|
|
117
|
+
const f = path.join(dir, name);
|
|
118
|
+
if (fs.existsSync(f) && fs.statSync(f).isFile()) return f;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseManifest(abs, text) {
|
|
124
|
+
try {
|
|
125
|
+
if (/\.json$/i.test(abs)) return { ok: true, value: JSON.parse(text) || {} };
|
|
126
|
+
return { ok: true, value: YAML.parse(text) || {} };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return { ok: false, error: e.message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function collectTags(obj) {
|
|
133
|
+
const candidates = [obj.tags, obj.keywords, obj.categories];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const v of candidates) {
|
|
136
|
+
if (Array.isArray(v)) out.push(...v.filter(x => typeof x === 'string'));
|
|
137
|
+
else if (typeof v === 'string') out.push(...v.split(',').map(x => x.trim()).filter(Boolean));
|
|
138
|
+
}
|
|
139
|
+
return [...new Set(out)];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectLinks(obj) {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const [label, value] of Object.entries({ homepage: obj.homepage, repository: obj.repository, url: obj.url })) {
|
|
145
|
+
if (typeof value === 'string') out.push({ label, url: value });
|
|
146
|
+
else if (value && typeof value.url === 'string') out.push({ label, url: value.url });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|