|
@@ -0,0 +1,503 @@
|
|
|
|
|
+// 自定义技能(docs/design/12-custom-skills.md):后台编写或从技能源导入,统一存为版本(文件清单 + 按内容寻址的文件库),
|
|
|
|
|
+// 发布时把指定版本整体写入 custom-skills/skills/<name>(Claude 插件格式,两个引擎共用,技能 ID 为 custom-skills:<name>)。
|
|
|
|
|
+// 运行时只读生效目录;启用、发布、停用都通过整体替换目录完成,运行中的对话不会读到一半新一半旧的文件
|
|
|
|
|
+import crypto from 'node:crypto'
|
|
|
|
|
+import fs from 'node:fs'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { createTwoFilesPatch } from 'diff'
|
|
|
|
|
+import { and, eq } from 'drizzle-orm'
|
|
|
|
|
+import { strToU8, zipSync } from 'fflate'
|
|
|
|
|
+import {
|
|
|
|
|
+ CUSTOM_SKILL_PLUGIN, type CustomSkillDetailDto, type CustomSkillDto, type CustomSkillFileDto, type CustomSkillVersionDto, type FileDiffDto, type SkillInfo,
|
|
|
|
|
+} from '@agent-server/shared'
|
|
|
|
|
+import { customSkillsDir, customSkillStoreDir } from '../config'
|
|
|
|
|
+import type { DB } from '../db'
|
|
|
|
|
+import { customSkills, customSkillVersions, profiles, skillSources, type FileMap } from '../db/schema'
|
|
|
|
|
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
|
|
|
|
|
+import { newId, nowIso } from '../lib/ids'
|
|
|
|
|
+import { frontmatter } from '../lib/skill-files'
|
|
|
|
|
+import type { PluginRef } from '../providers/claude/skills'
|
|
|
|
|
+import { BlobStore, changesBetween, checkSkillLimits, isTextPath, LIMITS, mapDigest, NAME_RE, normalizeRelPath } from './store'
|
|
|
|
|
+
|
|
|
|
|
+type SkillRow = typeof customSkills.$inferSelect
|
|
|
|
|
+type VersionRow = typeof customSkillVersions.$inferSelect
|
|
|
|
|
+type Reason = VersionRow['reason']
|
|
|
|
|
+
|
|
|
|
|
+const MARKER = '.agent-server.json'
|
|
|
|
|
+
|
|
|
|
|
+export const skillId = (name: string) => `${CUSTOM_SKILL_PLUGIN}:${name}`
|
|
|
|
|
+
|
|
|
|
|
+/** 新建技能时的 SKILL.md 模版 */
|
|
|
|
|
+export function skillTemplate(name: string, description: string) {
|
|
|
|
|
+ return `---
|
|
|
|
|
+name: ${name}
|
|
|
|
|
+description: ${description.replace(/\n/g, ' ')}
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# ${name}
|
|
|
|
|
+
|
|
|
|
|
+## 什么时候使用
|
|
|
|
|
+
|
|
|
|
|
+(说明触发这个技能的场景。description 决定 Agent 是否会读取本技能,请写清楚。)
|
|
|
|
|
+
|
|
|
|
|
+## 步骤
|
|
|
|
|
+
|
|
|
|
|
+1.
|
|
|
|
|
+
|
|
|
|
|
+## 注意事项
|
|
|
|
|
+
|
|
|
|
|
+-
|
|
|
|
|
+`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 校验 SKILL.md:strict 时要求 name 与技能名一致、description 非空 */
|
|
|
|
|
+export function checkSkillMd(name: string, content: Buffer | undefined, strict: boolean): { description: string; warnings: string[] } {
|
|
|
|
|
+ if (!content) throw badRequest('缺少 SKILL.md')
|
|
|
|
|
+ if (content.length > LIMITS.skillMd) throw badRequest(`SKILL.md 超过 ${LIMITS.skillMd / 1000} KB 上限`)
|
|
|
|
|
+ const fm = frontmatter(content.toString('utf8'))
|
|
|
|
|
+ const warnings: string[] = []
|
|
|
|
|
+ if (fm.name !== name) {
|
|
|
|
|
+ const msg = fm.name ? `SKILL.md 中的 name(${fm.name})与技能名(${name})不一致` : 'SKILL.md 缺少 name'
|
|
|
|
|
+ if (strict) throw badRequest(msg)
|
|
|
|
|
+ warnings.push(msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ const description = fm.description ?? ''
|
|
|
|
|
+ if (!description.trim()) {
|
|
|
|
|
+ if (strict) throw badRequest('SKILL.md 缺少 description(它决定 Agent 是否会使用这个技能)')
|
|
|
|
|
+ warnings.push('SKILL.md 缺少 description')
|
|
|
|
|
+ } else if (description.length < 20) {
|
|
|
|
|
+ warnings.push('description 过短:它决定 Agent 是否会使用这个技能,建议写清楚适用场景')
|
|
|
|
|
+ } else if (description.length > 1024) {
|
|
|
|
|
+ warnings.push('description 超过 1024 个字符,部分引擎可能截断')
|
|
|
|
|
+ }
|
|
|
|
|
+ return { description, warnings }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class CustomSkillService {
|
|
|
|
|
+ readonly store: BlobStore
|
|
|
|
|
+ private skillsRoot: string
|
|
|
|
|
+ private tmpRoot: string
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private db: DB,
|
|
|
|
|
+ private audit: (summary: string, detail?: unknown) => void,
|
|
|
|
|
+ private roots = { live: customSkillsDir(), store: customSkillStoreDir() },
|
|
|
|
|
+ ) {
|
|
|
|
|
+ this.store = new BlobStore(roots.store)
|
|
|
|
|
+ this.skillsRoot = path.join(roots.live, 'skills')
|
|
|
|
|
+ this.tmpRoot = `${roots.live}-tmp`
|
|
|
|
|
+ this.ensurePlugin()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 插件清单:Claude 用它作为技能前缀,Codex 也据此给 extraRoots 中的技能加前缀 */
|
|
|
|
|
+ private ensurePlugin() {
|
|
|
|
|
+ fs.mkdirSync(this.skillsRoot, { recursive: true, mode: 0o755 })
|
|
|
|
|
+ const manifest = path.join(this.roots.live, '.claude-plugin', 'plugin.json')
|
|
|
|
|
+ if (!fs.existsSync(manifest)) {
|
|
|
|
|
+ fs.mkdirSync(path.dirname(manifest), { recursive: true })
|
|
|
|
|
+ fs.writeFileSync(manifest, JSON.stringify({ name: CUSTOM_SKILL_PLUGIN, version: '0.1.0', description: '自定义技能(由 AgentServer 管理)' }, null, 2))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 查询 ----
|
|
|
|
|
+
|
|
|
|
|
+ list(): CustomSkillDto[] {
|
|
|
|
|
+ const usage = this.usage()
|
|
|
|
|
+ const sources = this.sourceNames()
|
|
|
|
|
+ return this.db.select().from(customSkills).orderBy(customSkills.name).all().map(r => this.toDto(r, usage, sources))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ detail(id: string): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const versions = this.db.select().from(customSkillVersions).where(eq(customSkillVersions.skillId, id)).orderBy(customSkillVersions.version).all()
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...this.toDto(r, this.usage(), this.sourceNames()),
|
|
|
|
|
+ files: fileList(r.workingFiles),
|
|
|
|
|
+ versions: versions.map(toVersionDto).reverse(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ readFile(id: string, rawPath: string, version?: number): { data: Buffer; name: string; text: boolean } {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const rel = normalizeRelPath(rawPath)
|
|
|
|
|
+ const files = version === undefined ? r.workingFiles : this.version(id, version).files
|
|
|
|
|
+ const f = files[rel]
|
|
|
|
|
+ if (!f) throw notFound(`文件 ${rel}`)
|
|
|
|
|
+ return { data: this.store.get(f.sha), name: path.posix.basename(rel), text: isTextPath(rel) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 编写 ----
|
|
|
|
|
+
|
|
|
|
|
+ create(input: { name: string; description: string }): CustomSkillDetailDto {
|
|
|
|
|
+ const name = input.name.trim()
|
|
|
|
|
+ if (!NAME_RE.test(name)) throw badRequest('技能名只能用小写字母、数字和连字符(1-64 位,以字母或数字开头)')
|
|
|
|
|
+ if (!input.description?.trim()) throw badRequest('请填写描述:它决定 Agent 是否会使用这个技能')
|
|
|
|
|
+ this.checkCapacity(name)
|
|
|
|
|
+ const files: FileMap = { 'SKILL.md': this.store.put(skillTemplate(name, input.description.trim())) }
|
|
|
|
|
+ const id = newId('csk')
|
|
|
|
|
+ this.db.transaction(tx => {
|
|
|
|
|
+ tx.insert(customSkills).values({ id, name, description: input.description.trim(), origin: 'authored', latestVersion: 1, workingFiles: files }).run()
|
|
|
|
|
+ tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'create', files, changed: changesBetween({}, files) }).run()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.audit(`新建自定义技能 ${name}`)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ writeFile(id: string, rawPath: string, data: Buffer): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.editable(id)
|
|
|
|
|
+ const rel = normalizeRelPath(rawPath)
|
|
|
|
|
+ if (data.length > LIMITS.bytesPerFile) throw badRequest(`单个文件不能超过 ${LIMITS.bytesPerFile / 1048576} MB`)
|
|
|
|
|
+ const next = { ...r.workingFiles, [rel]: this.store.put(data) }
|
|
|
|
|
+ checkSkillLimits(next)
|
|
|
|
|
+ this.setWorking(r.id, next)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ deleteFile(id: string, rawPath: string): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.editable(id)
|
|
|
|
|
+ const rel = normalizeRelPath(rawPath)
|
|
|
|
|
+ if (rel === 'SKILL.md') throw badRequest('SKILL.md 不能删除')
|
|
|
|
|
+ // 删除文件,或以该路径为前缀的整个目录
|
|
|
|
|
+ const next = Object.fromEntries(Object.entries(r.workingFiles).filter(([p]) => p !== rel && !p.startsWith(`${rel}/`)))
|
|
|
|
|
+ if (Object.keys(next).length === Object.keys(r.workingFiles).length) throw notFound(`文件 ${rel}`)
|
|
|
|
|
+ this.setWorking(r.id, next)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 重命名文件或目录 */
|
|
|
|
|
+ move(id: string, rawFrom: string, rawTo: string): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.editable(id)
|
|
|
|
|
+ const from = normalizeRelPath(rawFrom)
|
|
|
|
|
+ const to = normalizeRelPath(rawTo)
|
|
|
|
|
+ if (from === 'SKILL.md' || to === 'SKILL.md') throw badRequest('SKILL.md 不能重命名')
|
|
|
|
|
+ const next: FileMap = {}
|
|
|
|
|
+ let moved = 0
|
|
|
|
|
+ for (const [p, f] of Object.entries(r.workingFiles)) {
|
|
|
|
|
+ const target = p === from ? to : p.startsWith(`${from}/`) ? `${to}${p.slice(from.length)}` : p
|
|
|
|
|
+ if (target !== p) moved++
|
|
|
|
|
+ if (next[target]) throw badRequest(`目标已存在:${target}`)
|
|
|
|
|
+ next[target] = f
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!moved) throw notFound(`文件 ${from}`)
|
|
|
|
|
+ this.setWorking(r.id, next)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 放弃未保存的改动,回到最新版本 */
|
|
|
|
|
+ discard(id: string): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ this.setWorking(r.id, this.version(id, r.latestVersion).files)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 把编辑中的改动保存为新版本(内容无变化时不产生新版本),可同时发布 */
|
|
|
|
|
+ save(id: string, opts: { baseVersion: number; publish: boolean }): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.editable(id)
|
|
|
|
|
+ if (opts.baseVersion !== r.latestVersion) throw conflict('version_conflict', `技能已更新到版本 ${r.latestVersion},请刷新后再保存`)
|
|
|
|
|
+ const latest = this.version(id, r.latestVersion).files
|
|
|
|
|
+ const changed = changesBetween(latest, r.workingFiles)
|
|
|
|
|
+ const dirty = changed.added.length + changed.modified.length + changed.removed.length > 0
|
|
|
|
|
+ const md = r.workingFiles['SKILL.md'] ? this.store.get(r.workingFiles['SKILL.md'].sha) : undefined
|
|
|
|
|
+ const { description } = checkSkillMd(r.name, md, true)
|
|
|
|
|
+ checkSkillLimits(r.workingFiles)
|
|
|
|
|
+ let version = r.latestVersion
|
|
|
|
|
+ if (dirty) {
|
|
|
|
|
+ version = this.addVersion(r, r.workingFiles, 'edit', description)
|
|
|
|
|
+ this.audit(`保存自定义技能 ${r.name} 版本 ${version}`, changed)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (opts.publish) this.publish(id, version)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 发布某个版本(运行时生效) */
|
|
|
|
|
+ publish(id: string, version: number): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const v = this.version(id, version)
|
|
|
|
|
+ this.db.update(customSkills).set({ publishedVersion: version, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
|
|
|
|
|
+ if (r.enabled) this.materialize(r.name, v.files, version)
|
|
|
|
|
+ this.audit(`发布自定义技能 ${r.name} 版本 ${version}`)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 恢复:把旧版本另存为新版本并发布 */
|
|
|
|
|
+ restore(id: string, version: number): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const old = this.version(id, version)
|
|
|
|
|
+ const desc = frontmatter(old.files['SKILL.md'] ? this.store.get(old.files['SKILL.md'].sha).toString('utf8') : '').description ?? r.description
|
|
|
|
|
+ const v = this.addVersion(r, old.files, 'restore', desc)
|
|
|
|
|
+ this.setWorking(id, old.files)
|
|
|
|
|
+ this.audit(`恢复自定义技能 ${r.name} 到版本 ${version}(保存为版本 ${v})`)
|
|
|
|
|
+ return this.publish(id, v)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setFlags(id: string, flags: { enabled?: boolean; includeInAll?: boolean }): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const enabled = flags.enabled ?? r.enabled
|
|
|
|
|
+ const includeInAll = flags.includeInAll ?? r.includeInAll
|
|
|
|
|
+ this.db.update(customSkills).set({ enabled, includeInAll, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
|
|
|
|
|
+ if (enabled !== r.enabled) {
|
|
|
|
|
+ if (enabled && r.publishedVersion !== null) this.materialize(r.name, this.version(id, r.publishedVersion).files, r.publishedVersion)
|
|
|
|
|
+ if (!enabled) this.unpublishDir(r.name)
|
|
|
|
|
+ this.audit(`${enabled ? '启用' : '停用'}自定义技能 ${r.name}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (includeInAll !== r.includeInAll) this.audit(`自定义技能 ${r.name}${includeInAll ? '包含在' : '不再包含在'}"全部技能"中`)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 导入的技能转为可编辑:之后与来源脱钩,不再同步 */
|
|
|
|
|
+ detach(id: string): CustomSkillDetailDto {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ if (r.origin !== 'imported') throw badRequest('这个技能已经是可编辑的')
|
|
|
|
|
+ this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
|
|
|
|
|
+ this.audit(`自定义技能 ${r.name} 转为可编辑(与来源脱钩)`)
|
|
|
|
|
+ return this.detail(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ delete(id: string) {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const sid = skillId(r.name)
|
|
|
|
|
+ for (const p of this.db.select().from(profiles).all()) {
|
|
|
|
|
+ if (Array.isArray(p.skills) && p.skills.includes(sid)) {
|
|
|
|
|
+ this.db.update(profiles).set({ skills: p.skills.filter(s => s !== sid) }).where(eq(profiles.id, p.id)).run()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.db.transaction(tx => {
|
|
|
|
|
+ tx.delete(customSkillVersions).where(eq(customSkillVersions.skillId, id)).run()
|
|
|
|
|
+ tx.delete(customSkills).where(eq(customSkills.id, id)).run()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.unpublishDir(r.name)
|
|
|
|
|
+ this.gc()
|
|
|
|
|
+ this.audit(`删除自定义技能 ${r.name}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 导入(由技能源调用) ----
|
|
|
|
|
+
|
|
|
|
|
+ /** 导入一份文件清单:新技能直接创建;已从同一来源导入过的生成新版本。导入后立即发布 */
|
|
|
|
|
+ importFiles(input: { name: string; files: FileMap; sourceId: string; description: string }): { id: string; version: number; created: boolean } {
|
|
|
|
|
+ checkSkillLimits(input.files)
|
|
|
|
|
+ const existing = this.db.select().from(customSkills).where(eq(customSkills.name, input.name)).get()
|
|
|
|
|
+ if (existing && existing.sourceId !== input.sourceId) throw badRequest(`已存在同名技能 ${input.name},且来源不同`)
|
|
|
|
|
+ if (!existing) {
|
|
|
|
|
+ this.checkCapacity(input.name)
|
|
|
|
|
+ const id = newId('csk')
|
|
|
|
|
+ this.db.transaction(tx => {
|
|
|
|
|
+ tx.insert(customSkills).values({
|
|
|
|
|
+ id, name: input.name, description: input.description, origin: 'imported', sourceId: input.sourceId, latestVersion: 1, workingFiles: input.files,
|
|
|
|
|
+ }).run()
|
|
|
|
|
+ tx.insert(customSkillVersions).values({ skillId: id, version: 1, reason: 'import', files: input.files, changed: changesBetween({}, input.files) }).run()
|
|
|
|
|
+ })
|
|
|
|
|
+ this.publish(id, 1)
|
|
|
|
|
+ return { id, version: 1, created: true }
|
|
|
|
|
+ }
|
|
|
|
|
+ const v = this.addVersion(existing, input.files, 'sync', input.description)
|
|
|
|
|
+ this.setWorking(existing.id, input.files)
|
|
|
|
|
+ this.publish(existing.id, v)
|
|
|
|
|
+ return { id: existing.id, version: v, created: false }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ latestFiles(name: string): { files: FileMap; sourceId: string | null } | null {
|
|
|
|
|
+ const r = this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()
|
|
|
|
|
+ return r ? { files: this.version(r.id, r.latestVersion).files, sourceId: r.sourceId } : null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 版本对比与导出 ----
|
|
|
|
|
+
|
|
|
|
|
+ /** from / to:版本号,或 'working'(编辑中的改动) */
|
|
|
|
|
+ diff(id: string, from: number | 'working', to: number | 'working'): FileDiffDto[] {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const files = (v: number | 'working') => (v === 'working' ? r.workingFiles : this.version(id, v).files)
|
|
|
|
|
+ const a = files(from)
|
|
|
|
|
+ const b = files(to)
|
|
|
|
|
+ const c = changesBetween(a, b)
|
|
|
|
|
+ const text = (p: string, m: FileMap) => {
|
|
|
|
|
+ const f = m[p]
|
|
|
|
|
+ return f && f.size <= 1048576 ? this.store.get(f.sha).toString('utf8') : null
|
|
|
|
|
+ }
|
|
|
|
|
+ const patch = (p: string, oldText: string | null, newText: string | null) =>
|
|
|
|
|
+ isTextPath(p) && (oldText !== null || newText !== null)
|
|
|
|
|
+ ? createTwoFilesPatch(`a/${p}`, `b/${p}`, oldText ?? '', newText ?? '', String(from), String(to), { context: 3 })
|
|
|
|
|
+ : null
|
|
|
|
|
+ return [
|
|
|
|
|
+ ...c.added.map(p => ({ path: p, status: 'added' as const, binary: !isTextPath(p), patch: patch(p, null, text(p, b)) })),
|
|
|
|
|
+ ...c.modified.map(p => ({ path: p, status: 'modified' as const, binary: !isTextPath(p), patch: patch(p, text(p, a), text(p, b)) })),
|
|
|
|
|
+ ...c.removed.map(p => ({ path: p, status: 'removed' as const, binary: !isTextPath(p), patch: patch(p, text(p, a), null) })),
|
|
|
|
|
+ ].sort((x, y) => x.path.localeCompare(y.path))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 导出为 zip(顶层目录为技能名,可以直接作为技能目录使用) */
|
|
|
|
|
+ exportZip(id: string, version?: number): { name: string; data: Buffer } {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ const v = version ?? r.latestVersion
|
|
|
|
|
+ const entries: Record<string, Uint8Array> = {}
|
|
|
|
|
+ for (const [p, f] of Object.entries(this.version(id, v).files)) entries[`${r.name}/${p}`] = new Uint8Array(this.store.get(f.sha))
|
|
|
|
|
+ if (!Object.keys(entries).length) entries[`${r.name}/`] = strToU8('')
|
|
|
|
|
+ return { name: `${r.name}-v${v}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 运行时 ----
|
|
|
|
|
+
|
|
|
|
|
+ /** Claude:按路径加载为本地插件;没有包含在"全部"中的技能需要在 Profile 中单独勾选 */
|
|
|
|
|
+ pluginRef(): PluginRef | null {
|
|
|
|
|
+ const live = this.live()
|
|
|
|
|
+ if (!live.length) return null
|
|
|
|
|
+ return { name: CUSTOM_SKILL_PLUGIN, path: this.roots.live, skills: live.map(r => ({ id: skillId(r.name), loadId: skillId(r.name), optIn: !r.includeInAll })) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ skillInfos(): SkillInfo[] {
|
|
|
|
|
+ return this.live().map(r => ({
|
|
|
|
|
+ id: skillId(r.name), name: r.name, description: r.description, plugin: CUSTOM_SKILL_PLUGIN, source: 'custom' as const, license: 'open' as const, optIn: !r.includeInAll,
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 选择"全部技能"时不包含的技能(Codex 据此禁用) */
|
|
|
|
|
+ excludedFromAll(): string[] {
|
|
|
|
|
+ return this.live().filter(r => !r.includeInAll).map(r => skillId(r.name))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 生效内容的版本:发布、启停变化时不同(Codex 已加载的会话据此分叉,ADR-0020) */
|
|
|
|
|
+ fingerprint(): string {
|
|
|
|
|
+ const sig = this.live().map(r => [r.name, r.publishedVersion, r.includeInAll])
|
|
|
|
|
+ return sig.length ? crypto.createHash('sha256').update(JSON.stringify(sig)).digest('hex').slice(0, 12) : '0'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 启动时按数据库重建生效目录:缺失或版本不一致的重新发布,多余的目录删除 */
|
|
|
|
|
+ reconcile(log: (msg: string) => void = () => {}) {
|
|
|
|
|
+ fs.rmSync(this.tmpRoot, { recursive: true, force: true })
|
|
|
|
|
+ const expected = new Map(this.live().map(r => [r.name, r]))
|
|
|
|
|
+ for (const [name, r] of expected) {
|
|
|
|
|
+ const v = this.version(r.id, r.publishedVersion!)
|
|
|
|
|
+ const marker = readMarker(path.join(this.skillsRoot, name, MARKER))
|
|
|
|
|
+ if (!marker || marker.version !== r.publishedVersion || marker.digest !== mapDigest(v.files)) {
|
|
|
|
|
+ this.materialize(name, v.files, r.publishedVersion!)
|
|
|
|
|
+ log(`自定义技能 ${name} 已按版本 ${r.publishedVersion} 重新发布`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const d of fs.readdirSync(this.skillsRoot, { withFileTypes: true })) {
|
|
|
|
|
+ if (d.isDirectory() && !expected.has(d.name)) {
|
|
|
|
|
+ fs.rmSync(path.join(this.skillsRoot, d.name), { recursive: true, force: true })
|
|
|
|
|
+ log(`移除了不属于任何自定义技能的目录 ${d.name}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 删除不再被任何版本或编辑中改动引用的文件内容 */
|
|
|
|
|
+ gc(): number {
|
|
|
|
|
+ const refs = new Set<string>()
|
|
|
|
|
+ for (const v of this.db.select({ files: customSkillVersions.files }).from(customSkillVersions).all()) for (const f of Object.values(v.files)) refs.add(f.sha)
|
|
|
|
|
+ for (const s of this.db.select({ files: customSkills.workingFiles }).from(customSkills).all()) for (const f of Object.values(s.files)) refs.add(f.sha)
|
|
|
|
|
+ return this.store.gc(refs)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 内部 ----
|
|
|
|
|
+
|
|
|
|
|
+ private live(): SkillRow[] {
|
|
|
|
|
+ return this.db.select().from(customSkills).all().filter(r => r.enabled && r.publishedVersion !== null)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private row(id: string): SkillRow {
|
|
|
|
|
+ const r = this.db.select().from(customSkills).where(eq(customSkills.id, id)).get()
|
|
|
|
|
+ if (!r) throw notFound('自定义技能')
|
|
|
|
|
+ return r
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private editable(id: string): SkillRow {
|
|
|
|
|
+ const r = this.row(id)
|
|
|
|
|
+ if (r.origin === 'imported') throw new AppError(409, 'imported_skill', '导入的技能只读(与来源保持一致);需要修改时先"转为可编辑"')
|
|
|
|
|
+ return r
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private version(id: string, version: number): VersionRow {
|
|
|
|
|
+ const v = this.db.select().from(customSkillVersions).where(and(eq(customSkillVersions.skillId, id), eq(customSkillVersions.version, version))).get()
|
|
|
|
|
+ if (!v) throw notFound(`版本 ${version}`)
|
|
|
|
|
+ return v
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private addVersion(r: SkillRow, files: FileMap, reason: Reason, description: string): number {
|
|
|
|
|
+ const prev = this.version(r.id, r.latestVersion).files
|
|
|
|
|
+ const version = r.latestVersion + 1
|
|
|
|
|
+ this.db.transaction(tx => {
|
|
|
|
|
+ tx.insert(customSkillVersions).values({ skillId: r.id, version, reason, files, changed: changesBetween(prev, files) }).run()
|
|
|
|
|
+ tx.update(customSkills).set({ latestVersion: version, description, updatedAt: nowIso() }).where(eq(customSkills.id, r.id)).run()
|
|
|
|
|
+ })
|
|
|
|
|
+ return version
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private setWorking(id: string, files: FileMap) {
|
|
|
|
|
+ this.db.update(customSkills).set({ workingFiles: files, updatedAt: nowIso() }).where(eq(customSkills.id, id)).run()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private checkCapacity(name: string) {
|
|
|
|
|
+ if (this.db.select().from(customSkills).where(eq(customSkills.name, name)).get()) throw badRequest(`技能名 ${name} 已被使用`)
|
|
|
|
|
+ if (this.db.select({ id: customSkills.id }).from(customSkills).all().length >= LIMITS.skills) throw badRequest(`自定义技能最多 ${LIMITS.skills} 个`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 先写入临时目录,再整体替换生效目录 */
|
|
|
|
|
+ private materialize(name: string, files: FileMap, version: number) {
|
|
|
|
|
+ fs.mkdirSync(this.tmpRoot, { recursive: true })
|
|
|
|
|
+ const rand = crypto.randomBytes(4).toString('hex')
|
|
|
|
|
+ const tmp = path.join(this.tmpRoot, `${name}-${rand}`)
|
|
|
|
|
+ for (const [p, f] of Object.entries(files)) {
|
|
|
|
|
+ const target = path.join(tmp, ...p.split('/'))
|
|
|
|
|
+ fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
|
|
|
+ fs.writeFileSync(target, this.store.get(f.sha))
|
|
|
|
|
+ }
|
|
|
|
|
+ fs.mkdirSync(tmp, { recursive: true })
|
|
|
|
|
+ fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify({ version, digest: mapDigest(files) }))
|
|
|
|
|
+ const dest = path.join(this.skillsRoot, name)
|
|
|
|
|
+ const old = path.join(this.tmpRoot, `${name}-old-${rand}`)
|
|
|
|
|
+ if (fs.existsSync(dest)) fs.renameSync(dest, old)
|
|
|
|
|
+ fs.renameSync(tmp, dest)
|
|
|
|
|
+ fs.rmSync(old, { recursive: true, force: true })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private unpublishDir(name: string) {
|
|
|
|
|
+ const dest = path.join(this.skillsRoot, name)
|
|
|
|
|
+ if (!fs.existsSync(dest)) return
|
|
|
|
|
+ fs.mkdirSync(this.tmpRoot, { recursive: true })
|
|
|
|
|
+ const old = path.join(this.tmpRoot, `${name}-old-${crypto.randomBytes(4).toString('hex')}`)
|
|
|
|
|
+ fs.renameSync(dest, old)
|
|
|
|
|
+ fs.rmSync(old, { recursive: true, force: true })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private usage(): Map<string, string[]> {
|
|
|
|
|
+ const out = new Map<string, string[]>()
|
|
|
|
|
+ for (const p of this.db.select({ name: profiles.name, skills: profiles.skills }).from(profiles).all()) {
|
|
|
|
|
+ if (!Array.isArray(p.skills)) continue
|
|
|
|
|
+ for (const s of p.skills) if (s.startsWith(`${CUSTOM_SKILL_PLUGIN}:`)) out.set(s, [...(out.get(s) ?? []), p.name])
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private sourceNames(): Map<string, string> {
|
|
|
|
|
+ return new Map(this.db.select({ id: skillSources.id, name: skillSources.name }).from(skillSources).all().map(s => [s.id, s.name]))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private toDto(r: SkillRow, usage: Map<string, string[]>, sources: Map<string, string>): CustomSkillDto {
|
|
|
|
|
+ const latest = this.db.select({ files: customSkillVersions.files }).from(customSkillVersions)
|
|
|
|
|
+ .where(and(eq(customSkillVersions.skillId, r.id), eq(customSkillVersions.version, r.latestVersion))).get()
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: r.id, name: r.name, description: r.description, origin: r.origin, sourceId: r.sourceId, sourceName: r.sourceId ? sources.get(r.sourceId) ?? null : null,
|
|
|
|
|
+ enabled: r.enabled, includeInAll: r.includeInAll, publishedVersion: r.publishedVersion, latestVersion: r.latestVersion,
|
|
|
|
|
+ hasUnsavedChanges: !!latest && mapDigest(latest.files) !== mapDigest(r.workingFiles),
|
|
|
|
|
+ profileNames: usage.get(skillId(r.name)) ?? [], createdAt: r.createdAt, updatedAt: r.updatedAt,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function fileList(m: FileMap): CustomSkillFileDto[] {
|
|
|
|
|
+ return Object.entries(m).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))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toVersionDto(v: VersionRow): CustomSkillVersionDto {
|
|
|
|
|
+ const files = Object.values(v.files)
|
|
|
|
|
+ return { version: v.version, createdAt: v.createdAt, reason: v.reason, changed: v.changed, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0) }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function readMarker(f: string): { version: number; digest: string } | null {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(fs.readFileSync(f, 'utf8')) as { version: number; digest: string }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|