| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- #!/usr/bin/env node
- /**
- * normalize-skill.mjs —— SKILL.md 规范化:把各仓库不同的 frontmatter 统一成 SkillHub 可检索格式
- *
- * 各源仓库现状(不统一):
- * - skill-storage: name + description + keywords(无触发词)
- * - skill-deliverable: slug + summary + tags(无 name/description)
- * - 其他: 各种变体
- *
- * SkillHub 检索依赖:frontmatter 的 name + description,且 description 内需含触发词。
- * 本模块负责:读入原始 SKILL.md → 输出统一格式(保留正文,补全 frontmatter)。
- */
- import fs from 'node:fs';
- import path from 'node:path';
- /** 解析 YAML frontmatter(轻量实现,只处理单行 key: value 与数组) */
- export function parseFrontmatter(content) {
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
- if (!m) return { fm: {}, body: content };
- const fm = {};
- const lines = m[1].split(/\r?\n/);
- let currentKey = null;
- for (const line of lines) {
- if (!line.trim()) continue;
- // 数组项
- if (/^\s*-\s+/.test(line) && currentKey) {
- const val = line.replace(/^\s*-\s+/, '').trim();
- if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
- fm[currentKey].push(val);
- continue;
- }
- // key: value
- const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
- if (kv) {
- currentKey = kv[1];
- let val = kv[2].trim();
- // 多行字符串(| 或 >)简化处理:取后续行拼接
- if (val === '|' || val === '>') {
- fm[currentKey] = '';
- continue;
- }
- // YAML 内联数组:[a, b, c]
- if (val.startsWith('[') && val.endsWith(']')) {
- fm[currentKey] = val
- .slice(1, -1)
- .split(',')
- .map(s => s.trim().replace(/^["']|["']$/g, ''))
- .filter(Boolean);
- continue;
- }
- // 去引号
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
- val = val.slice(1, -1);
- }
- fm[currentKey] = val;
- } else if (currentKey && typeof fm[currentKey] === 'string') {
- // 多行描述续行
- fm[currentKey] = (fm[currentKey] + ' ' + line.trim()).trim();
- }
- }
- return { fm, body: content.slice(m[0].length) };
- }
- /** 从各字段中收集关键词 */
- function collectKeywords(fm) {
- const out = new Set();
- // 品牌词、层级词、通用词不入触发词
- const BLOCK = /^(FmodeAgent|FmodeCode|Hermes(\s*Agent)?|Claude(\s*Code)?|Codex|GPT|DeepSeek|Agent|未来飞马|智能体技能|超级技能|平台基础设施|系统级|服务级|应用级|系统|服务|应用|skill|技能)$/i;
- const push = (v) => {
- if (!v) return;
- const arr = Array.isArray(v) ? v : String(v).split(/[,,、\s]+/);
- for (const item of arr) {
- const t = String(item).trim();
- // 过滤品牌名、通用词、过长项
- if (!t || t.length > 20) continue;
- if (BLOCK.test(t)) continue;
- out.add(t);
- }
- };
- push(fm.keywords);
- push(fm.tags);
- push(fm.trigger);
- push(fm.triggers);
- return [...out];
- }
- /** 从正文中提取一级/二级标题作为场景线索 */
- function collectHeadings(body) {
- const out = [];
- const re = /^#{1,2}\s+(.+)$/gm;
- let m;
- while ((m = re.exec(body)) !== null) {
- const h = m[1].trim();
- if (h.length > 1 && h.length < 30) out.push(h);
- if (out.length >= 8) break;
- }
- return out;
- }
- /** 判断 description 里是否已有触发词标记 */
- function hasTrigger(desc) {
- return /触发词[::]/.test(desc || '');
- }
- /**
- * 规范化单个技能目录下的 SKILL.md
- * @returns {{ok:boolean, issues:string[], output?:string, meta?:object}}
- */
- export function normalizeSkill(skillDir, skillName) {
- const skillMd = path.join(skillDir, 'SKILL.md');
- if (!fs.existsSync(skillMd)) {
- return { ok: false, issues: ['缺少 SKILL.md'] };
- }
- const raw = fs.readFileSync(skillMd, 'utf8');
- const { fm, body } = parseFrontmatter(raw);
- const issues = [];
- // name:优先 name,其次 slug,最后用目录名
- const name = fm.name || fm.slug || skillName;
- // description:优先 description,其次 summary,最后从正文首段提取
- let description = fm.description || fm.summary || '';
- if (!description) {
- const firstPara = body
- .split(/\n\n+/)
- .map(s => s.trim())
- .find(s => s && !s.startsWith('#') && !s.startsWith('|') && !s.startsWith('>'));
- description = firstPara ? firstPara.slice(0, 200) : `${skillName} 技能`;
- }
- // 触发词:已有则保留;没有则自动生成并追加
- let triggers = [];
- if (hasTrigger(description)) {
- const tm = /触发词[::]\s*(.+?)(?:。|$)/.exec(description);
- if (tm) triggers = tm[1].split(/[,,、\s]+/).filter(Boolean);
- } else {
- triggers = collectKeywords(fm);
- if (triggers.length < 3) {
- // 关键词不足时,用标题补
- const heads = collectHeadings(body);
- for (const h of heads) {
- if (triggers.length >= 6) break;
- if (!triggers.includes(h)) triggers.push(h);
- }
- }
- if (triggers.length) {
- description = description.replace(/\s+$/, '') + ` 触发词:${triggers.join('、')}`;
- } else {
- issues.push('无法生成触发词(keywords/tags/标题均不足)');
- }
- }
- // 组装统一 frontmatter
- const outFm = {
- name,
- description: description.replace(/\s+/g, ' ').trim(),
- version: fm.version || fm.schema_version || '1.0.0',
- license: fm.license || 'MPL-2.0',
- author: 'Fmode (未来飞马)',
- };
- if (triggers.length) outFm.keywords = triggers.slice(0, 12);
- const fmText = Object.entries(outFm)
- .map(([k, v]) => {
- if (Array.isArray(v)) return `${k}: [${v.join(', ')}]`;
- return `${k}: ${JSON.stringify(v)}`;
- })
- .join('\n');
- const output = `---\n${fmText}\n---\n${body}`;
- return { ok: issues.length === 0, issues, output, meta: { name, triggers } };
- }
- /** CLI 用法:node normalize-skill.mjs <skillDir> <skillName> */
- if (process.argv[1] && process.argv[1].endsWith('normalize-skill.mjs')) {
- const [dir, name] = process.argv.slice(2);
- if (!dir) {
- console.error('用法: node normalize-skill.mjs <skillDir> <skillName>');
- process.exit(1);
- }
- const r = normalizeSkill(dir, name || path.basename(dir));
- if (r.ok) {
- console.log(r.output);
- } else {
- console.error('规范化失败:', r.issues.join('; '));
- process.exit(1);
- }
- }
|