#!/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 */ 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 '); 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); } }