index.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. /**
  2. * skill-core-guide — Fmode Harness 平台母技能 · ESM 入口
  3. * ---------------------------------------------------------------------------
  4. * 导出平台常量、端点真值表、ESM-first 校验器、技能清单与六项质检引擎。
  5. *
  6. * 设计纪律:
  7. * 1. 零依赖(no dependencies)—— 母技能必须能在任何 Node ≥18 环境裸跑。
  8. * 2. 纯 ESM —— 无 CJS 入口(团队共识)。
  9. * 3. 浏览器安全子集在 browser/index.mjs;本文件允许 import node: 内置模块。
  10. *
  11. * @example
  12. * import { PLATFORM, ENDPOINTS, validatePackage, CHECKS } from 'skill-core-guide';
  13. * const r = await validatePackage('./my-skill');
  14. * console.log(r.ok, r.errors);
  15. */
  16. export const VERSION = '1.0.0';
  17. export const SKILL_NAME = 'skill-core-guide';
  18. // 本地绑定:供本文件内的 CHANNELS / publishPlan 等使用
  19. // (注意:`export ... from` 只转发、不产生本地绑定,故需显式 import)
  20. import { PLATFORM, PACKAGE_RULES } from './platform.mjs';
  21. // ---------- 平台真值源 ----------
  22. export {
  23. PLATFORM,
  24. ENDPOINTS,
  25. endpointsByStatus,
  26. CREDENTIAL_CHAIN,
  27. TOKEN_RULES,
  28. TIERS,
  29. RUNTIMES,
  30. REQUIRED_LAYOUT,
  31. PACKAGE_RULES,
  32. } from './platform.mjs';
  33. // ---------- 质检引擎 ----------
  34. export {
  35. CHECKS,
  36. CHECKS_BY_ID,
  37. runChecks,
  38. checkFunctional,
  39. checkApiConnectivity,
  40. checkSop,
  41. checkDashboard,
  42. checkLoop,
  43. checkMultiRuntime,
  44. summarize,
  45. renderReport,
  46. } from './check.mjs';
  47. // ---------- 凭证供给 ----------
  48. export {
  49. resolveSessionToken,
  50. resolveApiToken,
  51. resolveFmodeDir,
  52. resolveConfigPath,
  53. validateToken,
  54. fetchApiTokenFromSession,
  55. ensureFmodeDir,
  56. writeConfig,
  57. writeCredential,
  58. requestVerifyCode,
  59. verifyAndProvision,
  60. bootstrap,
  61. describeBootstrapStatus,
  62. maskToken,
  63. } from './bootstrap.mjs';
  64. // ---------- 技能清单 ----------
  65. export { INVENTORY, byTier, byPlatform, stats } from './inventory.mjs';
  66. // ============================================================
  67. // 技能分类与命名规则
  68. // ============================================================
  69. /** 技能命名规则:必须以 skill- 前缀(平台约定) */
  70. export const NAMING = {
  71. prefix: 'skill-',
  72. /** npm 上部分技能以 fmode- 前缀发布(历史兼容),两者均合法 */
  73. altPrefix: 'fmode-',
  74. pattern: /^(skill|fmode)-[a-z0-9]+(-[a-z0-9]+)*$/,
  75. /** skillhub.cn 上的 slug 规则 */
  76. slugPattern: /^fmode-skill-[a-z0-9]+(-[a-z0-9]+)*$/,
  77. };
  78. /**
  79. * 校验技能名是否符合平台命名规范。
  80. * @param {string} name
  81. * @returns {{ok: boolean, reason?: string, slug?: string}}
  82. */
  83. export function validateName(name) {
  84. if (!name || typeof name !== 'string') {
  85. return { ok: false, reason: '技能名不能为空' };
  86. }
  87. if (!NAMING.pattern.test(name)) {
  88. return {
  89. ok: false,
  90. reason: `技能名 "${name}" 不符合规范:须为 skill-<kebab-case> 或 fmode-<kebab-case>(仅小写字母、数字、连字符)`,
  91. };
  92. }
  93. return { ok: true, slug: `fmode-${name}` };
  94. }
  95. // ============================================================
  96. // 包结构 / package.json 校验器
  97. // ============================================================
  98. /**
  99. * 校验 package.json 是否符合 ESM-first 多端标准。
  100. * 纯函数——不触碰文件系统,可在浏览器中运行。
  101. *
  102. * @param {object} pkg 已解析的 package.json 对象
  103. * @returns {{ok: boolean, errors: string[], warnings: string[]}}
  104. */
  105. export function validatePackageJson(pkg) {
  106. const errors = [];
  107. const warnings = [];
  108. if (!pkg || typeof pkg !== 'object') {
  109. return { ok: false, errors: ['package.json 无法解析或不是对象'], warnings };
  110. }
  111. for (const f of PACKAGE_RULES.requiredFields) {
  112. if (pkg[f] === undefined || pkg[f] === null || pkg[f] === '') {
  113. errors.push(`缺少必需字段:${f}`);
  114. }
  115. }
  116. if (pkg.type !== PACKAGE_RULES.type) {
  117. errors.push(`"type" 必须是 "${PACKAGE_RULES.type}"(当前:${JSON.stringify(pkg.type)})—— ESM only`);
  118. }
  119. if (pkg.main !== PACKAGE_RULES.main) {
  120. errors.push(`"main" 必须是 "${PACKAGE_RULES.main}"(当前:${JSON.stringify(pkg.main)})`);
  121. }
  122. // exports['.'] 必须同时提供 import 与 default
  123. const dot = pkg.exports && pkg.exports['.'];
  124. if (!dot) {
  125. errors.push('"exports" 缺少 "." 入口');
  126. } else if (typeof dot === 'string') {
  127. warnings.push('"exports[\".\"]" 是字符串简写;建议显式写成 { import, default } 以对齐四端标准');
  128. } else {
  129. for (const cond of PACKAGE_RULES.exportConditions) {
  130. if (!dot[cond]) errors.push(`"exports[\".\"]" 缺少 "${cond}" 条件`);
  131. }
  132. }
  133. // bin 必须是对象且指向 .mjs
  134. if (pkg.bin && typeof pkg.bin === 'object') {
  135. const entries = Object.entries(pkg.bin);
  136. if (entries.length === 0) errors.push('"bin" 为空对象');
  137. for (const [cmd, target] of entries) {
  138. if (!String(target).endsWith('.mjs')) {
  139. warnings.push(`bin["${cmd}"] 指向 ${target},建议使用 .mjs 扩展名以对齐 ESM 标准`);
  140. }
  141. }
  142. } else if (pkg.bin) {
  143. warnings.push('"bin" 建议使用对象形式 { "<cmd>": "./bin/<name>.mjs" }');
  144. }
  145. // files 白名单覆盖度
  146. if (Array.isArray(pkg.files)) {
  147. for (const need of PACKAGE_RULES.filesMustInclude) {
  148. if (!pkg.files.some((f) => f === need || f === need.replace(/\/$/, ''))) {
  149. warnings.push(`"files" 白名单建议包含 "${need}"(避免发布缺文件)`);
  150. }
  151. }
  152. } else {
  153. warnings.push('建议声明 "files" 白名单,避免把 test/ 与临时文件发到 npm');
  154. }
  155. // ESM-only 纪律:不应有 require 字段
  156. for (const f of PACKAGE_RULES.forbiddenFields) {
  157. if (pkg[f] !== undefined) {
  158. errors.push(`不应出现 "${f}" 字段 —— 本平台 ESM only,不提供 CJS 入口`);
  159. }
  160. }
  161. // 命名规范
  162. if (pkg.name) {
  163. const n = validateName(pkg.name);
  164. if (!n.ok) warnings.push(n.reason);
  165. }
  166. if (!pkg.license) errors.push('缺少 "license"(平台统一 MIT)');
  167. return { ok: errors.length === 0, errors, warnings };
  168. }
  169. /**
  170. * 校验 skill-package-manifest.json 的结构。
  171. * @param {object} manifest
  172. * @returns {{ok: boolean, errors: string[], warnings: string[]}}
  173. */
  174. export function validateManifest(manifest) {
  175. const errors = [];
  176. const warnings = [];
  177. if (!manifest || typeof manifest !== 'object') {
  178. return { ok: false, errors: ['skill-package-manifest.json 无法解析'], warnings };
  179. }
  180. for (const f of ['name', 'version', 'description', 'skills']) {
  181. if (!manifest[f]) errors.push(`清单缺少必需字段:${f}`);
  182. }
  183. if (manifest.skills !== undefined) {
  184. if (!Array.isArray(manifest.skills) || manifest.skills.length === 0) {
  185. errors.push('"skills" 必须是非空数组');
  186. } else {
  187. manifest.skills.forEach((s, i) => {
  188. if (!s || typeof s !== 'object') {
  189. errors.push(`skills[${i}] 不是对象`);
  190. return;
  191. }
  192. for (const f of ['name', 'path']) {
  193. if (!s[f]) errors.push(`skills[${i}] 缺少 "${f}"`);
  194. }
  195. if (s.path && !String(s.path).endsWith('SKILL.md')) {
  196. warnings.push(`skills[${i}].path 建议指向 SKILL.md(当前:${s.path})`);
  197. }
  198. });
  199. }
  200. }
  201. if (!manifest.install) {
  202. warnings.push('建议声明 "install" 字段(如 "npx --yes <skill>@latest workspace")');
  203. }
  204. return { ok: errors.length === 0, errors, warnings };
  205. }
  206. // ============================================================
  207. // SKILL.md frontmatter 解析与校验(三种格式)
  208. // ============================================================
  209. /**
  210. * 极简 YAML frontmatter 解析器(零依赖)。
  211. * 仅支持平台技能用到的子集:标量、行内数组 [a, b]、引号字符串。
  212. *
  213. * @param {string} text SKILL.md 全文
  214. * @returns {{data: Record<string, unknown>, body: string, raw: string|null}}
  215. */
  216. export function parseFrontmatter(text) {
  217. if (typeof text !== 'string') return { data: {}, body: '', raw: null };
  218. const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
  219. if (!m) return { data: {}, body: text, raw: null };
  220. const raw = m[1];
  221. const body = text.slice(m[0].length);
  222. const data = {};
  223. for (const line of raw.split(/\r?\n/)) {
  224. const t = line.trim();
  225. if (!t || t.startsWith('#')) continue;
  226. const idx = t.indexOf(':');
  227. if (idx <= 0) continue;
  228. const key = t.slice(0, idx).trim();
  229. let val = t.slice(idx + 1).trim();
  230. // 去引号
  231. if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
  232. val = val.slice(1, -1);
  233. } else if (val.startsWith('[') && val.endsWith(']')) {
  234. data[key] = val
  235. .slice(1, -1)
  236. .split(',')
  237. .map((s) => s.trim().replace(/^["']|["']$/g, ''))
  238. .filter(Boolean);
  239. continue;
  240. }
  241. data[key] = val;
  242. }
  243. return { data, body, raw };
  244. }
  245. /** 三种 frontmatter 格式的必需字段 */
  246. export const FRONTMATTER_SCHEMAS = {
  247. /** Hermes 本地技能格式 */
  248. hermes: {
  249. key: 'hermes',
  250. label: 'Hermes 本地技能格式',
  251. required: ['name', 'description', 'version'],
  252. recommended: ['tags', 'license', 'author'],
  253. },
  254. /** skillhub.cn 格式 */
  255. skillhub: {
  256. key: 'skillhub',
  257. label: 'skillhub.cn 格式',
  258. required: ['slug', 'displayName', 'version', 'summary', 'license'],
  259. recommended: ['tags'],
  260. },
  261. /** GitHub/Gogs README 格式(无 frontmatter,用标题结构校验) */
  262. readme: {
  263. key: 'readme',
  264. label: 'GitHub/Gogs README 格式',
  265. required: [],
  266. recommended: [],
  267. },
  268. };
  269. /**
  270. * 校验 frontmatter 是否符合指定格式。
  271. * @param {string} text
  272. * @param {'hermes'|'skillhub'} format
  273. * @returns {{ok: boolean, data: object, errors: string[], warnings: string[]}}
  274. */
  275. export function validateFrontmatter(text, format = 'hermes') {
  276. const schema = FRONTMATTER_SCHEMAS[format];
  277. const errors = [];
  278. const warnings = [];
  279. if (!schema) {
  280. return { ok: false, data: {}, errors: [`未知的 frontmatter 格式:${format}`], warnings };
  281. }
  282. const { data, raw } = parseFrontmatter(text);
  283. if (raw === null) {
  284. return { ok: false, data: {}, errors: [`未找到 YAML frontmatter(文件须以 --- 开头)`], warnings };
  285. }
  286. for (const f of schema.required) {
  287. if (data[f] === undefined || data[f] === '') errors.push(`frontmatter 缺少必需字段:${f}`);
  288. }
  289. for (const f of schema.recommended) {
  290. if (data[f] === undefined) warnings.push(`frontmatter 建议补充:${f}`);
  291. }
  292. if (format === 'skillhub' && data.slug) {
  293. if (!NAMING.slugPattern.test(String(data.slug))) {
  294. warnings.push(`slug "${data.slug}" 建议符合 fmode-skill-<kebab-case>(当前团队 slug 规范)`);
  295. }
  296. }
  297. if (format === 'hermes' && data.name) {
  298. const n = validateName(String(data.name));
  299. if (!n.ok) warnings.push(n.reason);
  300. }
  301. return { ok: errors.length === 0, data, errors, warnings };
  302. }
  303. // ============================================================
  304. // 分发渠道
  305. // ============================================================
  306. export const CHANNELS = {
  307. gogs: {
  308. key: 'gogs',
  309. label: 'Gogs(主仓 · 内网日常迭代)',
  310. remote: 'origin',
  311. url: (name) => `${PLATFORM.gogsBase}/${PLATFORM.gogsOrg}/${name}.git`,
  312. addRemote: (name) =>
  313. `git remote add origin ${PLATFORM.gogsBase}/${PLATFORM.gogsOrg}/${name}.git`,
  314. push: 'git push origin master',
  315. note: '凭据通过 URL 携带(内网 Gogs 的 /api/v1 未开放匿名访问,建仓需走 Web UI 或已登录会话)。',
  316. },
  317. github: {
  318. key: 'github',
  319. label: 'GitHub(公开镜像)',
  320. remote: 'github',
  321. url: (name) => `git@github.com:${PLATFORM.githubOrg}/${name}.git`,
  322. addRemote: (name) => `git remote add github git@github.com:${PLATFORM.githubOrg}/${name}.git`,
  323. push: 'GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_fmodecn" git push github master',
  324. note: 'SSH key: ~/.ssh/id_ed25519_fmodecn。GitHub 建仓可用 ~/.fmode/config.json 的 githubToken 调 REST API。',
  325. },
  326. npm: {
  327. key: 'npm',
  328. label: 'npm(SDK 分发)',
  329. url: (name) => `${PLATFORM.npmRegistry}/package/${name}`,
  330. publish: 'npm publish --access public',
  331. note: '账号 fmode001(凭据在 ~/.npmrc)。npm 上部分技能用 fmode- 前缀发布。',
  332. },
  333. skillhub: {
  334. key: 'skillhub',
  335. label: 'skillhub.cn(社区分发)',
  336. url: (slug) => `https://skillhub.cn/skill/${slug}`,
  337. installCli: `curl -fsSL ${PLATFORM.skillhub.cliInstall} | bash -s -- --cli-only`,
  338. login: `skillhub login --key <API_KEY> --host ${PLATFORM.skillhub.host}`,
  339. publish: (dir, changelog) =>
  340. `skillhub publish ${dir} --changelog "${changelog}"`,
  341. dryRun: (dir) => `skillhub publish ${dir} --dry-run`,
  342. note: 'CLI 位于 ~/.local/bin/skillhub。发布目录内必须含 SKILL.md,且 frontmatter 用 skillhub 格式(slug/displayName/summary)。',
  343. },
  344. };
  345. /** 按顺序返回四渠道发布步骤 */
  346. export function publishPlan(name, { dir = '.', changelog = '' } = {}) {
  347. return [
  348. { channel: 'gogs', ...CHANNELS.gogs, steps: [CHANNELS.gogs.addRemote(name), CHANNELS.gogs.push] },
  349. { channel: 'github', ...CHANNELS.github, steps: [CHANNELS.github.addRemote(name), CHANNELS.github.push] },
  350. { channel: 'npm', ...CHANNELS.npm, steps: [CHANNELS.npm.publish] },
  351. {
  352. channel: 'skillhub',
  353. ...CHANNELS.skillhub,
  354. steps: [CHANNELS.skillhub.dryRun(dir), CHANNELS.skillhub.publish(dir, changelog || `release ${name}`)],
  355. },
  356. ];
  357. }