// 技能发布平台(docs/design/18-skill-registry.md):项目用发布密钥推送技能包,管理员审核后新建技能或覆盖已有技能。 // 推送只产生待审核的提交项,不改变任何技能;提交项的文件清单在推送时存入文件库且不可修改,批准的就是审核时看到的内容 import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { and, desc, eq, gte, inArray } from 'drizzle-orm' import type { RegistryItemDto, RegistryMeDto, RegistrySubmissionDto, SkillApproveInput, SkillCheckDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, SkillSubmissionStatus, } from '@agent-server/shared' import type { DB } from '../db' import { skillPublishKeys, skillSubmissionItems, skillSubmissions, type FileMap, type SkillCheck } from '../db/schema' import { AppError, badRequest, conflict, notFound } from '../lib/errors' import { newId, nowIso } from '../lib/ids' import { hashKey } from '../services/catalog' import { detectSkillDirs, extractZip, readSkillDir, type PackageSkill } from './package' import type { CustomSkillService } from './service' import { changesBetween, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath, sha256 } from './store' type KeyRow = typeof skillPublishKeys.$inferSelect type ItemRow = typeof skillSubmissionItems.$inferSelect type SubmissionRow = typeof skillSubmissions.$inferSelect export const PUBLISH_KEY_PREFIX = 'sk_pub_' /** 每个密钥每小时的推送次数、待审核的提交项数 */ export const REGISTRY_LIMITS = { submissionsPerHour: 30, pendingPerKey: 50 } const SCRIPT_RE = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1|bat|cmd|php)$/i const SECRET_PATTERNS: [RegExp, string][] = [ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, '私钥'], [/\bAKIA[0-9A-Z]{16}\b/, 'AWS Access Key'], [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, 'API Key(sk-…)'], [/\bgh[pousr]_[A-Za-z0-9]{30,}\b/, 'GitHub 令牌'], [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, 'Slack 令牌'], [/\b(?:ak|bk|sk_pub)_[A-Za-z0-9_-]{24,}/, '本平台的密钥'], [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i, '带密码的连接串'], [/(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{12,}/i, '密码或密钥赋值'], ] const URL_RE = /\bhttps?:\/\/([a-z0-9.-]+\.[a-z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?/gi const DONE: SkillSubmissionStatus[] = ['approved', 'rejected', 'withdrawn', 'superseded', 'invalid', 'unchanged'] export class SkillRegistryService { constructor( private db: DB, private skills: CustomSkillService, private audit: (summary: string, detail?: unknown) => void, ) {} // ---- 发布密钥 ---- listKeys(): SkillPublishKeyDto[] { const counts = new Map() const subs = new Map(this.db.select({ id: skillSubmissions.id, keyId: skillSubmissions.keyId }).from(skillSubmissions).all().map(s => [s.id, s.keyId])) for (const s of subs.values()) counts.set(s, { submissions: (counts.get(s)?.submissions ?? 0) + 1, pending: counts.get(s)?.pending ?? 0 }) for (const i of this.db.select({ submissionId: skillSubmissionItems.submissionId }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all()) { const k = subs.get(i.submissionId) if (k && counts.get(k)) counts.get(k)!.pending++ } return this.db.select().from(skillPublishKeys).orderBy(desc(skillPublishKeys.createdAt)).all().map(r => ({ id: r.id, name: r.name, description: r.description, prefix: r.keyPrefix, enabled: r.enabled, expiresAt: r.expiresAt, origin: r.origin, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt, lastUsedIp: r.lastUsedIp, ...(counts.get(r.id) ?? { submissions: 0, pending: 0 }), })) } /** 返回明文密钥,只在创建时出现一次 */ createKey(input: SkillPublishKeyInput, origin: 'manual' | 'device' = 'manual'): { key: string; publishKey: SkillPublishKeyDto } { const name = input.name?.trim() if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字),它表示推送的来源,例如"fwh-app · GitHub Actions"') const days = input.expiresInDays if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 3650)) throw badRequest('有效期应在 1 ~ 3650 天之间,或选择永久') const key = `${PUBLISH_KEY_PREFIX}${crypto.randomBytes(24).toString('base64url')}` const id = newId('spk') this.db.insert(skillPublishKeys).values({ id, name, description: (input.description ?? '').slice(0, 500), keyHash: hashKey(key), keyPrefix: key.slice(0, 12), expiresAt: days === null ? null : new Date(Date.now() + days * 86_400_000).toISOString(), origin, }).run() this.audit(`新建技能发布密钥 ${name}(${key.slice(0, 12)}…${origin === 'device' ? ',推送工具浏览器登录' : ''})`) return { key, publishKey: this.listKeys().find(k => k.id === id)! } } updateKey(id: string, input: { name?: string; description?: string; enabled?: boolean }): SkillPublishKeyDto { const r = this.keyRow(id) const name = input.name === undefined ? r.name : input.name.trim() if (!name || name.length > 80) throw badRequest('请填写名称(不超过 80 个字)') const enabled = input.enabled ?? r.enabled this.db.update(skillPublishKeys).set({ name, description: (input.description ?? r.description).slice(0, 500), enabled }).where(eq(skillPublishKeys.id, id)).run() if (enabled !== r.enabled) this.audit(`${enabled ? '启用' : '停用'}技能发布密钥 ${name}`) else if (name !== r.name) this.audit(`技能发布密钥 ${r.name} 改名为 ${name}`) return this.listKeys().find(k => k.id === id)! } /** 删除密钥:已批准的技能不受影响,待审核的提交项保留 */ deleteKey(id: string) { const r = this.keyRow(id) this.db.delete(skillPublishKeys).where(eq(skillPublishKeys.id, id)).run() this.audit(`删除技能发布密钥 ${r.name}(${r.keyPrefix}…)`) } /** 发布接口鉴权:返回密钥(启用且未过期),并记录使用时间与来源 IP */ verify(key: string, ip: string): KeyRow | null { if (!key.startsWith(PUBLISH_KEY_PREFIX)) return null const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.keyHash, hashKey(key))).get() if (!r || !r.enabled || (r.expiresAt && Date.parse(r.expiresAt) <= Date.now())) return null this.db.update(skillPublishKeys).set({ lastUsedAt: nowIso(), lastUsedIp: ip }).where(eq(skillPublishKeys.id, r.id)).run() return r } /** 推送工具 whoami:当前密钥的信息 */ me(key: KeyRow): RegistryMeDto { return { name: key.name, prefix: key.keyPrefix, expiresAt: key.expiresAt, origin: key.origin, createdAt: key.createdAt } } /** 推送工具 logout:停用当前密钥 */ revokeSelf(key: KeyRow) { this.db.update(skillPublishKeys).set({ enabled: false }).where(eq(skillPublishKeys.id, key.id)).run() this.audit(`推送工具退出登录,停用技能发布密钥 ${key.name}(${key.keyPrefix}…)`) } // ---- 推送(发布接口) ---- /** 推送一个技能包;dryRun 只校验不保存 */ submit(key: KeyRow, pkg: { data: Buffer; message: string; meta: Record }, dryRun: boolean): RegistrySubmissionDto { if (pkg.data.length > LIMITS.zipBytes) throw badRequest(`技能包不能超过 ${LIMITS.zipBytes / 1048576} MB`) const message = pkg.message.slice(0, 2000) const meta = Object.fromEntries(Object.entries(pkg.meta).slice(0, 20).map(([k, v]) => [k.slice(0, 40), String(v).slice(0, 300)])) if (!dryRun) { const since = new Date(Date.now() - 3600_000).toISOString() const recent = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(and(eq(skillSubmissions.keyId, key.id), gte(skillSubmissions.createdAt, since))).all().length if (recent >= REGISTRY_LIMITS.submissionsPerHour) throw new AppError(429, 'rate_limited', `每个密钥每小时最多推送 ${REGISTRY_LIMITS.submissionsPerHour} 次,请稍后再试`) } const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-push-')) try { const skipped = extractZip(pkg.data, tmp) const dirs = detectSkillDirs(tmp) if (!dirs.length) throw badRequest('技能包中没有找到技能(需要包含 SKILL.md 的目录:单个技能、<名称>/SKILL.md 或 skills/<名称>/SKILL.md)') const seen = new Set() const evaluated = dirs.map(d => { const sk = readSkillDir(d, skipped) if (!sk.reason && seen.has(sk.name)) sk.reason = `技能包中有多个名为 ${sk.name} 的技能` seen.add(sk.name) return this.evaluate(sk) }) const newPending = evaluated.filter(e => e.status === 'pending') if (!dryRun && newPending.length) { const replaced = new Set(newPending.map(e => e.skill.name)) const pending = this.pendingOfKey(key.id).filter(i => !replaced.has(i.name)).length if (pending + newPending.length > REGISTRY_LIMITS.pendingPerKey) { throw new AppError(429, 'too_many_pending', `待审核的技能最多 ${REGISTRY_LIMITS.pendingPerKey} 个,请等管理员处理后再推送`) } } const now = nowIso() if (dryRun) { return { id: '', status: newPending.length ? 'pending' : 'done', message, meta, createdAt: now, dryRun: true, skipped, items: evaluated.map(e => this.toRegistryItem({ id: '', status: e.status, reason: e.skill.reason, name: e.skill.name, declaredVersion: e.skill.declaredVersion, checks: e.checks, reviewNote: null, result: null, files: e.skill.map } as ItemRow)), } } // 文件存入文件库(按推送时的摘要核对) for (const e of evaluated) { if (e.skill.reason) continue for (const f of e.skill.files) { const stored = this.skills.store.put(fs.readFileSync(f.abs)) if (stored.sha !== e.skill.map[f.rel]!.sha) throw conflict('package_changed', '技能包在处理过程中发生了变化,请重新推送') } } const id = newId('sub') this.db.transaction(tx => { tx.insert(skillSubmissions).values({ id, keyId: key.id, keyName: key.name, message, meta, packageSha: sha256(pkg.data), packageSize: pkg.data.length, createdAt: now }).run() for (const e of evaluated) { if (e.status === 'pending') { // 同一密钥推送的同名技能只审核最新的一份 const old = this.pendingOfKey(key.id).filter(i => i.name === e.skill.name).map(i => i.id) if (old.length) tx.update(skillSubmissionItems).set({ status: 'superseded', reviewedAt: now }).where(inArray(skillSubmissionItems.id, old)).run() } tx.insert(skillSubmissionItems).values({ id: newId('sbi'), submissionId: id, name: e.skill.name, description: e.skill.description, declaredVersion: e.skill.declaredVersion, files: e.skill.reason ? {} : e.skill.map, checks: e.checks, status: e.status, reason: e.skill.reason, createdAt: now, }).run() } }) this.audit(`推送技能包(${key.name},${(pkg.data.length / 1024).toFixed(1)} KB,sha256 ${sha256(pkg.data).slice(0, 16)}…):${evaluated.map(e => `${e.skill.name}(${STATUS_TEXT[e.status]})`).join('、')}`, { submissionId: id, keyPrefix: key.keyPrefix, message, meta, skipped }) return { ...this.getSubmission(key.id, id), skipped } } finally { fs.rmSync(tmp, { recursive: true, force: true }) } } listSubmissions(keyId: string, opts: { status?: 'pending' | 'done'; limit?: number }): RegistrySubmissionDto[] { const subs = this.db.select().from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).orderBy(desc(skillSubmissions.createdAt)).all() const out = subs.map(s => this.toSubmission(s)).filter(s => !opts.status || s.status === opts.status) return out.slice(0, Math.min(Math.max(opts.limit ?? 20, 1), 100)) } getSubmission(keyId: string, id: string): RegistrySubmissionDto { const s = this.db.select().from(skillSubmissions).where(and(eq(skillSubmissions.id, id), eq(skillSubmissions.keyId, keyId))).get() if (!s) throw notFound('提交') return this.toSubmission(s) } /** 撤回提交中仍在待审核的技能 */ withdraw(keyId: string, id: string): RegistrySubmissionDto { const s = this.getSubmission(keyId, id) const ids = s.items.filter(i => i.status === 'pending').map(i => i.id) if (ids.length) { this.db.update(skillSubmissionItems).set({ status: 'withdrawn', reviewedAt: nowIso() }).where(inArray(skillSubmissionItems.id, ids)).run() this.audit(`撤回推送 ${id}:${s.items.filter(i => ids.includes(i.id)).map(i => i.name).join('、')}`) } return this.getSubmission(keyId, id) } // ---- 审核(后台) ---- pendingCount(): number { return this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.status, 'pending')).all().length } listItems(filter: 'pending' | 'done'): SkillSubmissionItemDto[] { const rows = this.db.select().from(skillSubmissionItems) .where(filter === 'pending' ? eq(skillSubmissionItems.status, 'pending') : inArray(skillSubmissionItems.status, DONE)) .orderBy(desc(skillSubmissionItems.createdAt)).all() const subs = this.submissionsById(rows.map(r => r.submissionId)) return rows.map(r => this.toItem(r, subs.get(r.submissionId)!)) } item(id: string): SkillSubmissionItemDetailDto { const r = this.itemRow(id) return { ...this.toItem(r, this.submissionsById([r.submissionId]).get(r.submissionId)!), files: Object.entries(r.files).map(([p, f]) => ({ path: p, size: f.size, sha: f.sha, text: isTextPath(p) })) .sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path)), } } readFile(id: string, rawPath: string): { data: Buffer; name: string; text: boolean } { const r = this.itemRow(id) const rel = normalizeRelPath(rawPath) const f = r.files[rel] if (!f) throw notFound(`文件 ${rel}`) return { data: this.skills.store.get(f.sha), name: path.posix.basename(rel), text: isTextPath(rel) } } /** 差异:覆盖时与目标技能的最新版本对比;新建时与空技能对比。技能名与 SKILL.md 中的不同时按改写后的内容对比 */ diff(id: string, opts: { targetId?: string; name?: string }): SkillSubmissionDiffDto { const r = this.itemRow(id) const target = opts.targetId ? this.skills.latest(opts.targetId) : null const name = target?.name ?? opts.name?.trim() ?? r.name const files = this.withName(r.files, name) return { target: target && { id: target.id, name: target.name, version: target.version, declaredVersion: target.declaredVersion, unsaved: target.unsaved, updatedAt: target.updatedAt }, renamed: name !== r.name, files: this.skills.diffFiles(target?.files ?? {}, files, target ? `v${target.version}` : '(空)', '提交'), } } approve(id: string, input: SkillApproveInput): SkillSubmissionItemDto { const r = this.itemRow(id) if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了') const sub = this.submissionsById([r.submissionId]).get(r.submissionId)! const name = input.action === 'create' ? input.name.trim() : this.skills.latest(input.targetId).name if (input.action === 'create' && !NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)') const res = this.skills.applySubmission( input.action === 'create' ? { action: 'create', name } : { action: 'overwrite', id: input.targetId, expectedVersion: input.expectedVersion }, this.withName(r.files, name), { submissionId: sub.id, keyName: sub.keyName, message: sub.message }, input.publish, ) const result = { action: input.action, skillId: res.id, skillName: res.name, version: res.version, published: input.publish } this.db.update(skillSubmissionItems).set({ status: 'approved', result, reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run() this.audit(`批准推送的技能 ${r.name}(${sub.keyName}):${input.action === 'create' ? `新建技能 ${res.name}` : `覆盖技能 ${res.name}`} v${res.version}${input.publish ? ',已发布' : ',暂不发布'}`, { itemId: id, submissionId: sub.id }) return this.toItem(this.itemRow(id), sub) } reject(id: string, reason: string): SkillSubmissionItemDto { const r = this.itemRow(id) if (r.status !== 'pending') throw conflict('not_pending', '这个提交项已经处理过了') const note = reason.trim() if (!note) throw badRequest('请填写拒绝的原因(推送方可以看到)') this.db.update(skillSubmissionItems).set({ status: 'rejected', reviewNote: note.slice(0, 2000), reviewedAt: nowIso() }).where(eq(skillSubmissionItems.id, id)).run() const sub = this.submissionsById([r.submissionId]).get(r.submissionId)! this.audit(`拒绝推送的技能 ${r.name}(${sub.keyName}):${note}`, { itemId: id, submissionId: sub.id }) return this.toItem(this.itemRow(id), sub) } /** 删除已处理的提交项:不影响已生成的技能版本;提交中没有其他项时一并删除提交 */ deleteItem(id: string) { const r = this.itemRow(id) if (r.status === 'pending') throw conflict('pending', '待审核的提交项请先批准或拒绝') this.db.delete(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).run() if (!this.db.select({ id: skillSubmissionItems.id }).from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, r.submissionId)).get()) { this.db.delete(skillSubmissions).where(eq(skillSubmissions.id, r.submissionId)).run() } this.skills.gc() this.audit(`删除技能提交记录 ${r.name}(${STATUS_TEXT[r.status]})`) } // ---- 内部 ---- /** 推送时判定:校验不通过、与同名技能当前版本相同,或待审核(附自动检查) */ private evaluate(sk: PackageSkill): { skill: PackageSkill; status: SkillSubmissionStatus; checks: SkillCheck[] } { if (sk.reason) return { skill: sk, status: 'invalid', checks: [] } const existing = this.skills.byName(sk.name) const latest = existing ? this.skills.latest(existing.id) : null if (latest && mapDigest(latest.files) === mapDigest(sk.map)) return { skill: sk, status: 'unchanged', checks: [] } const checks: SkillCheck[] = sk.warnings.map(w => ({ level: 'warning' as const, code: 'skill_md' as const, message: w })) const scripts = sk.files.filter(f => SCRIPT_RE.test(f.rel)).map(f => f.rel) if (scripts.length) checks.push({ level: 'warning', code: 'script', message: `包含 ${scripts.length} 个脚本文件,请重点审核`, files: scripts }) const binaries = sk.files.filter(f => !isTextPath(f.rel)).map(f => f.rel) if (binaries.length) checks.push({ level: 'info', code: 'binary', message: `包含 ${binaries.length} 个二进制文件`, files: binaries }) const secrets = new Map() const hosts = new Set() for (const f of sk.files) { if (!isTextPath(f.rel) || f.size > 1048576) continue const text = fs.readFileSync(f.abs, 'utf8') for (const [re, label] of SECRET_PATTERNS) if (re.test(text)) secrets.set(label, [...(secrets.get(label) ?? []), f.rel]) for (const m of text.matchAll(URL_RE)) hosts.add(m[1]!.toLowerCase()) } for (const [label, files] of secrets) checks.push({ level: 'danger', code: 'secret', message: `疑似包含${label}`, files }) if (hosts.size) checks.push({ level: 'info', code: 'url', message: `引用的外部地址:${[...hosts].slice(0, 20).join('、')}${hosts.size > 20 ? ` 等 ${hosts.size} 个` : ''}` }) if (latest && sk.declaredVersion && sk.declaredVersion === latest.declaredVersion) { checks.push({ level: 'warning', code: 'version', message: `声明版本号仍为 ${sk.declaredVersion},但内容有变化` }) } else if (latest && (sk.declaredVersion || latest.declaredVersion)) { checks.push({ level: 'info', code: 'version', message: `声明版本号:${latest.declaredVersion ?? '未声明'} → ${sk.declaredVersion ?? '未声明'}` }) } return { skill: sk, status: 'pending', checks } } /** 技能名与 SKILL.md 中的 name 不同时,改写 SKILL.md 的 name */ private withName(files: FileMap, name: string): FileMap { const md = files['SKILL.md'] if (!md) return files const text = this.skills.store.get(md.sha).toString('utf8') const next = renameSkillMd(text, name) return next === text ? files : { ...files, 'SKILL.md': this.skills.store.put(next) } } private pendingOfKey(keyId: string): ItemRow[] { const subs = this.db.select({ id: skillSubmissions.id }).from(skillSubmissions).where(eq(skillSubmissions.keyId, keyId)).all().map(s => s.id) if (!subs.length) return [] return this.db.select().from(skillSubmissionItems).where(and(inArray(skillSubmissionItems.submissionId, subs), eq(skillSubmissionItems.status, 'pending'))).all() } private keyRow(id: string): KeyRow { const r = this.db.select().from(skillPublishKeys).where(eq(skillPublishKeys.id, id)).get() if (!r) throw notFound('发布密钥') return r } private itemRow(id: string): ItemRow { const r = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.id, id)).get() if (!r) throw notFound('提交项') return r } private submissionsById(ids: string[]): Map { if (!ids.length) return new Map() return new Map(this.db.select().from(skillSubmissions).where(inArray(skillSubmissions.id, [...new Set(ids)])).all().map(s => [s.id, s])) } private existingOf(name: string) { const r = this.skills.byName(name) if (!r) return null const l = this.skills.latest(r.id) return { id: r.id, version: l.version, declaredVersion: l.declaredVersion, files: l.files } } private toItem(r: ItemRow, s: SubmissionRow): SkillSubmissionItemDto { const e = r.status === 'pending' ? this.existingOf(r.name) : null const files = Object.values(r.files) return { id: r.id, submissionId: r.submissionId, name: r.name, description: r.description, declaredVersion: r.declaredVersion, status: r.status, reason: r.reason, checks: r.checks, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), keyName: s.keyName, message: s.message, meta: s.meta, createdAt: r.createdAt, existing: e && { id: e.id, version: e.version, declaredVersion: e.declaredVersion }, reviewNote: r.reviewNote, reviewedAt: r.reviewedAt, result: r.result, } } private toRegistryItem(r: ItemRow): RegistryItemDto { const e = r.status === 'pending' || r.status === 'unchanged' || !r.id ? this.existingOf(r.name) : null return { id: r.id, name: r.name, status: r.status, reason: r.reason, declaredVersion: r.declaredVersion, existing: e && { version: e.version, declaredVersion: e.declaredVersion }, changes: r.status === 'pending' ? changesBetween(e?.files ?? {}, r.files) : null, checks: r.checks, reviewNote: r.reviewNote, result: r.result && { action: r.result.action, skill: r.result.skillName, version: r.result.version, published: r.result.published }, } } private toSubmission(s: SubmissionRow): RegistrySubmissionDto { const items = this.db.select().from(skillSubmissionItems).where(eq(skillSubmissionItems.submissionId, s.id)).all() return { id: s.id, status: items.some(i => i.status === 'pending') ? 'pending' : 'done', message: s.message, meta: s.meta, createdAt: s.createdAt, dryRun: false, skipped: [], items: items.map(i => this.toRegistryItem(i)), } } } const STATUS_TEXT: Record = { pending: '待审核', approved: '已批准', rejected: '已拒绝', withdrawn: '已撤回', superseded: '已替代', invalid: '不能导入', unchanged: '无变化', } /** 改写 SKILL.md frontmatter 中的 name(没有 frontmatter 时补上) */ export function renameSkillMd(md: string, name: string): string { const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(md) if (!m) return `---\nname: ${name}\n---\n${md}` const body = /^name:.*$/m.test(m[2]!) ? m[2]!.replace(/^name:.*$/m, `name: ${name}`) : `name: ${name}\n${m[2]}` return m[1] + body + m[3] + md.slice(m[0].length) }