Bladeren bron

feat: 自定义技能(导入或在后台编写,两个引擎共用)

按 docs/design/12-custom-skills.md 实施一、二、三期:
- 存储:custom-skills(Claude 插件格式的生效目录,技能 ID custom-skills:<name>)+ 按 sha256 寻址的文件库;
  custom_skills / custom_skill_versions / skill_sources 三张表;发布时整体替换生效目录,启动时按数据库重建
- 技能源:登记服务器目录(插件格式 / 技能集合 / 单个技能),扫描显示差异,按扫描摘要导入(内容变化时拒绝);
  zip 上传(按实际解压字节数限制,跳过不安全路径并审计);自动同步(只导入新增与变化,不自动删除)
- 在线编写:文件树、文本编辑、二进制上传替换下载、重命名删除;保存草稿 / 保存并发布、放弃改动;
  版本历史、逐行对比、恢复为新版本、导出 zip;导入的技能只读,可转为可编辑
- 使用:默认需在 Profile 中单独勾选(可逐个设为包含在"全部技能"中);Claude 按路径加载、Codex 经 extraRoots,
  Codex 已加载会话按发布指纹分叉;停用即移除生效目录;删除时从 Profile 中移除
- 修复(ADR-0022,同样影响第三方技能):全局禁止的 {dataDir}/data 中只读放开服务管理的技能目录;
  Codex 父目录 deny 会压过子路径 read,禁止包含可读路径的目录时改为逐个禁止其他条目(也修复宽松模式)
- 测试覆盖验收用例 C-01 ~ C-08;Claude 端到端、Codex 权限布局、后台流程均已实测

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 week geleden
bovenliggende
commit
6f1a58212b

+ 531 - 0
apps/admin/src/components/CustomSkillsPanel.vue

@@ -0,0 +1,531 @@
+<script setup lang="ts">
+// 自定义技能(docs/design/12-custom-skills.md):左侧技能列表与技能源,右侧文件编辑;导入审阅、版本历史与对比用对话框
+import { computed, onMounted, ref } from 'vue'
+import type { CustomSkillDetailDto, CustomSkillDto, CustomSkillFileDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const skills = ref<CustomSkillDto[]>([])
+const sources = ref<SkillSourceDto[]>([])
+const current = ref<CustomSkillDetailDto | null>(null)
+const busy = ref<string | null>(null)
+
+const REASON: Record<string, string> = { create: '新建', edit: '编辑', import: '导入', sync: '同步', restore: '恢复' }
+
+async function load() {
+  try {
+    ;[skills.value, sources.value] = await Promise.all([admin.customSkills.list(), admin.skillSources.list()])
+    if (current.value) current.value = await admin.customSkills.get(current.value.id)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function run<T>(key: string, fn: () => Promise<T>, done?: string): Promise<T | undefined> {
+  busy.value = key
+  try {
+    const r = await fn()
+    if (done) toast(done, 'success')
+    return r
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+    return undefined
+  } finally {
+    busy.value = null
+  }
+}
+
+/** 更新当前技能并同步列表 */
+function apply(d: CustomSkillDetailDto) {
+  current.value = d
+  const i = skills.value.findIndex(s => s.id === d.id)
+  if (i >= 0) skills.value[i] = d
+  else skills.value.push(d)
+}
+
+// ---- 打开技能 / 编辑文件 ----
+const openPath = ref<string | null>(null)
+const editorText = ref('')
+const editorOrig = ref('')
+const editorDirty = computed(() => editorText.value !== editorOrig.value)
+const readonlySkill = computed(() => current.value?.origin === 'imported')
+
+async function openSkill(s: CustomSkillDto) {
+  if (!(await flush())) return
+  const d = await run('open', () => admin.customSkills.get(s.id))
+  if (!d) return
+  current.value = d
+  await openFile(d.files.find(f => f.path === 'SKILL.md') ?? d.files[0] ?? null)
+}
+
+async function openFile(f: CustomSkillFileDto | null) {
+  if (!(await flush())) return
+  openPath.value = f?.path ?? null
+  editorText.value = editorOrig.value = ''
+  if (f?.text && current.value) {
+    const t = await run('read', () => admin.customSkills.readText(current.value!.id, f.path))
+    editorText.value = editorOrig.value = t ?? ''
+  }
+}
+
+/**
+ * 把编辑器中的改动写入服务端(编辑中的改动,未保存为版本)。失焦时也会调用:不占用 busy,
+ * 否则按钮在点击前就被禁用;进行中的写入由后续操作等待
+ */
+let flushing: Promise<boolean> | null = null
+function flush(): Promise<boolean> {
+  if (flushing) return flushing.then(() => flush())
+  if (!current.value || !openPath.value || !editorDirty.value || readonlySkill.value) return Promise.resolve(true)
+  const id = current.value.id
+  const p = openPath.value
+  const text = editorText.value
+  flushing = admin.customSkills.writeText(id, p, text).then(d => {
+    apply(d)
+    if (openPath.value === p) editorOrig.value = text
+    return true
+  }, err => {
+    toast(errorMessage(err), 'error')
+    return false
+  }).finally(() => { flushing = null })
+  return flushing
+}
+
+const openFileInfo = computed(() => current.value?.files.find(f => f.path === openPath.value) ?? null)
+const fileGroups = computed(() => {
+  const files = current.value?.files ?? []
+  return files.map(f => ({ ...f, depth: f.path.split('/').length - 1, dir: f.path.includes('/') ? f.path.slice(0, f.path.lastIndexOf('/')) : '' }))
+})
+
+async function newFile() {
+  const p = window.prompt('新文件路径(相对技能目录),例如 references/guide.md')?.trim()
+  if (!p || !current.value) return
+  const d = await run('write', () => admin.customSkills.writeText(current.value!.id, p, ''))
+  if (d) {
+    apply(d)
+    await openFile(d.files.find(f => f.path === p) ?? null)
+  }
+}
+
+const uploadInput = ref<HTMLInputElement>()
+const uploadTarget = ref<string | null>(null)
+function pickUpload(target: string | null) {
+  uploadTarget.value = target
+  uploadInput.value?.click()
+}
+async function onUpload(e: Event) {
+  const file = (e.target as HTMLInputElement).files?.[0]
+  ;(e.target as HTMLInputElement).value = ''
+  if (!file || !current.value) return
+  const p = uploadTarget.value ?? window.prompt('上传到(相对技能目录的路径)', `templates/${file.name}`)?.trim()
+  if (!p) return
+  const d = await run('upload', () => admin.customSkills.upload(current.value!.id, p, file), `已上传 ${p}`)
+  if (d) apply(d)
+}
+
+async function renameFile(f: CustomSkillFileDto) {
+  const to = window.prompt('新路径', f.path)?.trim()
+  if (!to || to === f.path || !current.value) return
+  const d = await run('move', () => admin.customSkills.move(current.value!.id, f.path, to))
+  if (d) {
+    apply(d)
+    if (openPath.value === f.path) openPath.value = to
+  }
+}
+
+async function deleteFile(f: CustomSkillFileDto) {
+  if (!current.value || !window.confirm(`删除 ${f.path}?(保存为新版本前可以"放弃改动"恢复)`)) return
+  const d = await run('delete-file', () => admin.customSkills.deleteFile(current.value!.id, f.path))
+  if (d) {
+    apply(d)
+    if (openPath.value === f.path) await openFile(d.files[0] ?? null)
+  }
+}
+
+// ---- 保存 / 发布 / 版本 ----
+async function save(publish: boolean) {
+  if (!current.value || !(await flush())) return
+  const d = await run('save', () => admin.customSkills.save(current.value!.id, current.value!.latestVersion, publish), publish ? '已保存并发布' : '已保存为草稿(未发布)')
+  if (d) apply(d)
+}
+async function publishLatest() {
+  if (!current.value) return
+  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, current.value!.latestVersion), `已发布版本 ${current.value.latestVersion}`)
+  if (d) apply(d)
+}
+async function discard() {
+  if (!current.value || !window.confirm('放弃所有未保存的改动?')) return
+  const d = await run('discard', () => admin.customSkills.discard(current.value!.id))
+  if (d) {
+    apply(d)
+    await openFile(d.files.find(f => f.path === openPath.value) ?? d.files[0] ?? null)
+  }
+}
+async function setFlag(flags: { enabled?: boolean; includeInAll?: boolean }) {
+  if (!current.value) return
+  const d = await run('flags', () => admin.customSkills.setFlags(current.value!.id, flags))
+  if (d) apply(d)
+}
+async function detach() {
+  if (!current.value || !window.confirm('转为可编辑后,这个技能与来源脱钩,之后不再从来源同步。继续?')) return
+  const d = await run('detach', () => admin.customSkills.detach(current.value!.id), '已转为可编辑')
+  if (d) apply(d)
+}
+async function removeSkill() {
+  const s = current.value
+  if (!s) return
+  const used = s.profileNames.length ? `\n它正被 ${s.profileNames.join('、')} 使用,将从这些 Profile 中移除。` : ''
+  if (!window.confirm(`删除自定义技能「${s.name}」及其全部版本?此操作不可恢复。${used}`)) return
+  await run('remove', () => admin.customSkills.remove(s.id), '已删除')
+  current.value = null
+  openPath.value = null
+  await load()
+}
+
+async function createSkill() {
+  const name = window.prompt('技能名(小写字母、数字、连字符),例如 monthly-report')?.trim()
+  if (!name) return
+  const description = window.prompt('描述:说明什么时候使用这个技能(决定 Agent 是否会读取它)')?.trim()
+  if (!description) return
+  const d = await run('create', () => admin.customSkills.create(name, description), '已新建,编辑后保存并发布即可使用')
+  if (d) {
+    apply(d)
+    await openFile(d.files[0] ?? null)
+  }
+}
+
+// 版本与对比
+const showVersions = ref(false)
+const diff = ref<{ title: string; files: FileDiffDto[] } | null>(null)
+async function showDiff(from: number | 'working', to: number | 'working', title: string) {
+  if (!current.value) return
+  if (to === 'working' && !(await flush())) return
+  const files = await run('diff', () => admin.customSkills.diff(current.value!.id, from, to))
+  if (files) diff.value = { title, files }
+}
+async function restore(v: number) {
+  if (!current.value || !window.confirm(`恢复到版本 ${v}?将另存为新版本并立即发布。`)) return
+  const d = await run('restore', () => admin.customSkills.restore(current.value!.id, v), `已恢复并发布`)
+  if (d) {
+    apply(d)
+    showVersions.value = false
+    await openFile(d.files.find(f => f.path === openPath.value) ?? d.files[0] ?? null)
+  }
+}
+async function publishVersion(v: number) {
+  if (!current.value) return
+  const d = await run('publish', () => admin.customSkills.publish(current.value!.id, v), `已发布版本 ${v}`)
+  if (d) apply(d)
+}
+const lineClass = (l: string) => (l.startsWith('+') && !l.startsWith('+++') ? 'text-success' : l.startsWith('-') && !l.startsWith('---') ? 'text-error' : l.startsWith('@@') ? 'text-link' : 'text-fg-muted')
+
+// ---- 技能源 / 导入 ----
+const sourceDraft = ref<(SkillSourceInput & { id: string | null }) | null>(null)
+function editSource(s?: SkillSourceDto) {
+  sourceDraft.value = s
+    ? { id: s.id, name: s.name, description: s.description, path: s.path, syncMode: s.syncMode, intervalMin: s.intervalMin }
+    : { id: null, name: '', description: '', path: '', syncMode: 'manual', intervalMin: 10 }
+}
+async function saveSource() {
+  const d = sourceDraft.value!
+  const input: SkillSourceInput = { name: d.name, description: d.description, path: d.path, syncMode: d.syncMode, intervalMin: Number(d.intervalMin) }
+  const r = await run('source', () => (d.id ? admin.skillSources.update(d.id, input) : admin.skillSources.create(input)), '已保存')
+  if (!r) return
+  sourceDraft.value = null
+  await load()
+  if (!d.id) await scan(r)
+}
+async function removeSource(s: SkillSourceDto) {
+  const note = s.skillNames.length ? `\n从它导入的技能(${s.skillNames.join('、')})会保留,并转为可编辑。` : ''
+  if (!window.confirm(`删除技能源「${s.name}」?${note}`)) return
+  await run('source', () => admin.skillSources.remove(s.id), '已删除')
+  await load()
+}
+
+const scanResult = ref<{ source: SkillSourceDto; scan: SkillScanDto; selected: Record<string, boolean>; open: Record<string, boolean> } | null>(null)
+function showScan(source: SkillSourceDto, scan: SkillScanDto) {
+  const selected = Object.fromEntries(scan.skills.filter(s => s.status === 'new' || s.status === 'changed').map(s => [s.name, true]))
+  scanResult.value = { source, scan, selected, open: {} }
+}
+async function scan(s: SkillSourceDto) {
+  const r = await run(`scan:${s.id}`, () => admin.skillSources.scan(s.id))
+  if (r) showScan(s, r)
+  await load()
+}
+const zipInput = ref<HTMLInputElement>()
+async function onZip(e: Event) {
+  const file = (e.target as HTMLInputElement).files?.[0]
+  ;(e.target as HTMLInputElement).value = ''
+  if (!file) return
+  const r = await run('zip', () => admin.skillSources.upload(file))
+  if (r) {
+    await load()
+    showScan(r.source, r.scan)
+  }
+}
+async function doImport() {
+  const s = scanResult.value!
+  const names = Object.entries(s.selected).filter(([, v]) => v).map(([k]) => k)
+  if (!names.length) return
+  const r = await run('import', () => admin.skillSources.import(s.source.id, names, s.scan.digest))
+  if (!r) return
+  toast(`已导入并发布:${r.imported.map(x => `${x.name} v${x.version}`).join('、')}`, 'success')
+  scanResult.value = null
+  await load()
+}
+const STATUS: Record<string, [string, string]> = {
+  new: ['新技能', 'text-success'], changed: ['有变化', 'text-link'], unchanged: ['无变化', 'text-fg-muted'], conflict: ['同名冲突', 'text-error'], invalid: ['不能导入', 'text-error'],
+}
+
+const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
+const statusOf = (s: CustomSkillDto) => !s.enabled ? '已停用' : s.publishedVersion === null ? '未发布' : s.publishedVersion < s.latestVersion ? `v${s.publishedVersion} 生效 · 有新版本` : `v${s.publishedVersion} 生效`
+
+onMounted(load)
+defineExpose({ load })
+</script>
+
+<template>
+  <div class="flex-1 min-h-0 flex">
+    <!-- 左:技能与技能源 -->
+    <aside class="w-72 flex-none border-r border-r-solid border-border overflow-y-auto p-3 flex flex-col gap-4 text-[12.5px]">
+      <div class="flex flex-col gap-1">
+        <div class="flex items-center gap-1">
+          <span class="section-title flex-1">自定义技能</span>
+          <button type="button" class="icon-btn" title="新建技能" @click="createSkill"><span class="codicon codicon-add" /></button>
+          <button type="button" class="icon-btn" title="上传 zip 导入" @click="zipInput?.click()"><span class="codicon" :class="busy === 'zip' ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-upload'" /></button>
+          <input ref="zipInput" type="file" accept=".zip,application/zip" class="hidden" aria-label="上传技能 zip" @change="onZip">
+        </div>
+        <button
+          v-for="s in skills" :key="s.id" type="button"
+          class="text-left border-none rounded-md px-2 py-1.5 cursor-pointer flex flex-col gap-0.5"
+          :class="current?.id === s.id ? 'bg-active' : 'bg-transparent hover:bg-hover'" @click="openSkill(s)"
+        >
+          <span class="flex items-center gap-1.5">
+            <span class="codicon text-[12px]" :class="s.origin === 'imported' ? 'codicon-cloud-download text-link' : 'codicon-edit text-fg-muted'" :title="s.origin === 'imported' ? '导入' : '编写'" />
+            <span class="font-mono truncate" :class="{ 'line-through opacity-60': !s.enabled }">{{ s.name }}</span>
+            <span v-if="s.hasUnsavedChanges" class="w-1.5 h-1.5 rounded-full bg-warning flex-none" title="有未保存的改动" />
+          </span>
+          <span class="hint pl-4.5">{{ statusOf(s) }}<template v-if="s.sourceName"> · {{ s.sourceName }}</template></span>
+        </button>
+        <div v-if="!skills.length" class="hint px-2">还没有自定义技能。可以新建,或从技能源 / zip 导入。</div>
+      </div>
+
+      <div class="flex flex-col gap-1">
+        <div class="flex items-center gap-1">
+          <span class="section-title flex-1">技能源</span>
+          <button type="button" class="icon-btn" title="登记服务器目录" @click="editSource()"><span class="codicon codicon-add" /></button>
+        </div>
+        <div v-for="s in sources" :key="s.id" class="rounded-md border border-solid border-border p-2 flex flex-col gap-1">
+          <div class="flex items-center gap-1">
+            <span class="codicon text-[12px]" :class="s.kind === 'upload' ? 'codicon-file-zip' : 'codicon-folder'" />
+            <span class="font-500 truncate flex-1">{{ s.name }}</span>
+            <button type="button" class="icon-btn" :title="s.kind === 'upload' ? '查看并导入' : '扫描并同步'" :disabled="!!busy" @click="scan(s)">
+              <span class="codicon" :class="busy === `scan:${s.id}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />
+            </button>
+            <button v-if="s.kind === 'directory'" type="button" class="icon-btn" title="编辑" @click="editSource(s)"><span class="codicon codicon-edit" /></button>
+            <button type="button" class="icon-btn" title="删除" @click="removeSource(s)"><span class="codicon codicon-trash" /></button>
+          </div>
+          <div v-if="s.path" class="hint font-mono break-all">{{ s.path }}</div>
+          <div class="hint">
+            {{ s.kind === 'upload' ? '上传的 zip' : s.syncMode === 'auto' ? `自动同步(每 ${s.intervalMin} 分钟)` : '手动同步' }}
+            <template v-if="s.lastSyncAt"> · {{ formatTime(s.lastSyncAt) }}</template>
+          </div>
+          <div v-if="s.status === 'error'" class="text-error break-all">{{ s.lastError }}</div>
+          <div v-if="s.skillNames.length" class="hint">技能:{{ s.skillNames.join('、') }}</div>
+        </div>
+        <div v-if="!sources.length" class="hint px-2">登记服务器上的技能目录(插件格式、技能集合或单个技能),之后可以随时同步。服务看不到 home 下的目录。</div>
+      </div>
+    </aside>
+
+    <!-- 右:技能详情与编辑 -->
+    <section v-if="current" class="flex-1 min-w-0 flex flex-col">
+      <div class="flex-none border-b border-b-solid border-border px-4 py-2.5 flex flex-col gap-1.5 text-[12.5px]">
+        <div class="flex items-center gap-2 flex-wrap">
+          <span class="font-600 text-[14px] font-mono">{{ current.name }}</span>
+          <code class="font-mono text-[11.5px] text-fg-muted">custom-skills:{{ current.name }}</code>
+          <span class="badge bg-code">{{ current.origin === 'imported' ? `导入 · ${current.sourceName ?? '来源已删除'}` : '编写' }}</span>
+          <span class="badge bg-code" :class="current.publishedVersion === null ? 'text-warning' : 'text-success'">{{ current.publishedVersion === null ? '未发布' : `生效版本 v${current.publishedVersion}` }}</span>
+          <span class="hint">最新 v{{ current.latestVersion }}</span>
+          <span class="flex-1" />
+          <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" :checked="current.enabled" @change="setFlag({ enabled: !current.enabled })">启用</label>
+          <label class="flex items-center gap-1 cursor-pointer" title="选择&quot;全部技能&quot;的 Profile 也会加载它"><input type="checkbox" :checked="current.includeInAll" @change="setFlag({ includeInAll: !current.includeInAll })">包含在"全部技能"中</label>
+        </div>
+        <div class="hint line-clamp-2">{{ current.description }}</div>
+        <div class="hint">{{ current.profileNames.length ? `使用它的 Profile:${current.profileNames.join('、')}` : '还没有 Profile 勾选这个技能' }}</div>
+        <div class="flex items-center gap-1.5 flex-wrap">
+          <template v-if="!readonlySkill">
+            <button type="button" class="btn-primary" :disabled="!!busy" @click="save(true)">保存并发布</button>
+            <button type="button" class="btn-secondary" :disabled="!!busy" @click="save(false)">保存草稿</button>
+            <button type="button" class="btn-ghost" :disabled="!!busy || (!current.hasUnsavedChanges && !editorDirty)" @click="discard">放弃改动</button>
+          </template>
+          <button v-if="current.publishedVersion !== current.latestVersion" type="button" class="btn-secondary" :disabled="!!busy" @click="publishLatest">发布 v{{ current.latestVersion }}</button>
+          <button type="button" class="btn-ghost" @click="showVersions = true"><span class="codicon codicon-history" />版本历史</button>
+          <button v-if="!readonlySkill && (current.hasUnsavedChanges || editorDirty)" type="button" class="btn-ghost" @click="showDiff(current.latestVersion, 'working', '未保存的改动')"><span class="codicon codicon-diff" />查看改动</button>
+          <a :href="admin.customSkills.exportUrl(current.id)" class="btn-ghost no-underline"><span class="codicon codicon-desktop-download" />导出 zip</a>
+          <button v-if="readonlySkill" type="button" class="btn-ghost" @click="detach"><span class="codicon codicon-unlock" />转为可编辑</button>
+          <span class="flex-1" />
+          <button type="button" class="icon-btn" title="删除技能" @click="removeSkill"><span class="codicon codicon-trash" /></button>
+        </div>
+        <div v-if="readonlySkill" class="hint text-warning">导入的技能只读,内容与来源保持一致;修改请在来源中进行后同步,或"转为可编辑"。</div>
+      </div>
+
+      <div class="flex-1 min-h-0 flex">
+        <!-- 文件树 -->
+        <div class="w-60 flex-none border-r border-r-solid border-border overflow-y-auto p-2 flex flex-col gap-0.5 text-[12.5px]">
+          <div v-if="!readonlySkill" class="flex gap-1 mb-1">
+            <button type="button" class="btn-ghost !h-6 !px-2" @click="newFile"><span class="codicon codicon-new-file" />新文件</button>
+            <button type="button" class="btn-ghost !h-6 !px-2" @click="pickUpload(null)"><span class="codicon codicon-cloud-upload" />上传</button>
+            <input ref="uploadInput" type="file" class="hidden" aria-label="上传文件" @change="onUpload">
+          </div>
+          <div
+            v-for="f in fileGroups" :key="f.path" class="group flex items-center gap-1 rounded-sm px-1.5 py-0.5 cursor-pointer"
+            :class="openPath === f.path ? 'bg-active' : 'hover:bg-hover'" @click="openFile(f)"
+          >
+            <span class="codicon text-[12px] text-fg-muted" :class="f.text ? 'codicon-file' : 'codicon-file-binary'" />
+            <span class="truncate flex-1 font-mono text-[12px]" :title="f.path"><span class="text-fg-muted">{{ f.dir ? `${f.dir}/` : '' }}</span>{{ f.path.split('/').pop() }}</span>
+            <template v-if="!readonlySkill && f.path !== 'SKILL.md'">
+              <button type="button" class="icon-btn !w-5 !h-5 opacity-0 group-hover:opacity-100" title="重命名" @click.stop="renameFile(f)"><span class="codicon codicon-edit text-[11px]" /></button>
+              <button type="button" class="icon-btn !w-5 !h-5 opacity-0 group-hover:opacity-100" title="删除" @click.stop="deleteFile(f)"><span class="codicon codicon-trash text-[11px]" /></button>
+            </template>
+          </div>
+        </div>
+        <!-- 编辑器 -->
+        <div class="flex-1 min-w-0 flex flex-col p-3 gap-2">
+          <template v-if="openFileInfo">
+            <div class="flex items-center gap-2 text-[12px]">
+              <span class="font-mono">{{ openFileInfo.path }}</span>
+              <span class="hint">{{ fmtSize(openFileInfo.size) }}</span>
+              <span v-if="editorDirty" class="badge bg-code text-warning">已修改</span>
+              <span class="flex-1" />
+              <a :href="admin.customSkills.fileUrl(current.id, openFileInfo.path)" target="_blank" rel="noopener" class="btn-ghost !h-6 no-underline"><span class="codicon codicon-desktop-download" />下载</a>
+              <button v-if="!readonlySkill && !openFileInfo.text" type="button" class="btn-ghost !h-6" @click="pickUpload(openFileInfo.path)"><span class="codicon codicon-replace" />替换</button>
+            </div>
+            <textarea
+              v-if="openFileInfo.text" v-model="editorText" class="field-area flex-1 min-h-0 !resize-none !text-[12.5px] leading-relaxed" spellcheck="false"
+              :readonly="readonlySkill" :aria-label="`编辑 ${openFileInfo.path}`" @blur="flush"
+            />
+            <div v-else class="flex-1 flex items-center justify-center hint">二进制文件(模版、图片等)不能在线编辑,可以下载或替换。</div>
+            <div v-if="openFileInfo.path === 'SKILL.md'" class="hint">frontmatter 必须包含 name(与技能名一致)和 description;description 决定 Agent 是否会使用这个技能。</div>
+          </template>
+          <div v-else class="flex-1 flex items-center justify-center hint">选择左侧的文件</div>
+        </div>
+      </div>
+    </section>
+    <section v-else class="flex-1 flex items-center justify-center hint text-[12.5px] px-8 text-center">
+      选择左侧的技能进行编辑。自定义技能两个引擎通用(ID 为 custom-skills:&lt;名称&gt;),需要在 Profile 中勾选后才会使用。
+    </section>
+
+    <!-- 技能源编辑 -->
+    <div v-if="sourceDraft" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="sourceDraft = null">
+      <form class="w-[min(560px,92vw)] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]" @submit.prevent="saveSource">
+        <div class="font-600 text-[14px]">{{ sourceDraft.id ? '编辑技能源' : '登记技能源(服务器目录)' }}</div>
+        <div><label class="label" for="ss-name">名称</label><input id="ss-name" v-model="sourceDraft.name" class="field" required></div>
+        <div>
+          <label class="label" for="ss-path">目录(绝对路径)</label>
+          <input id="ss-path" v-model="sourceDraft.path" class="field font-mono" placeholder="/srv/toolbox/skills" required>
+          <div class="hint mt-1">支持插件格式(skills/&lt;名称&gt;/SKILL.md)、技能集合(&lt;名称&gt;/SKILL.md)或单个技能。服务看不到 home 下的目录,需要放到 home 以外或配置绑定挂载。</div>
+        </div>
+        <div><label class="label" for="ss-desc">说明(可选)</label><input id="ss-desc" v-model="sourceDraft.description" class="field"></div>
+        <div class="flex items-center gap-4">
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="sourceDraft.syncMode" type="radio" value="manual">手动同步(导入前审阅差异)</label>
+          <label class="flex items-center gap-1.5 cursor-pointer"><input v-model="sourceDraft.syncMode" type="radio" value="auto">自动同步</label>
+          <template v-if="sourceDraft.syncMode === 'auto'">
+            <span>每</span><input v-model.number="sourceDraft.intervalMin" type="number" min="1" max="1440" class="field w-20"><span>分钟</span>
+          </template>
+        </div>
+        <div v-if="sourceDraft.syncMode === 'auto'" class="hint">自动同步会直接导入并发布有变化的技能(不经审阅);来源中删除的技能不会自动删除。</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="sourceDraft = null">取消</button>
+          <button type="submit" class="btn-primary" :disabled="!!busy">保存并扫描</button>
+        </div>
+      </form>
+    </div>
+
+    <!-- 扫描结果 / 导入审阅 -->
+    <div v-if="scanResult" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="scanResult = null">
+      <div class="w-[min(760px,94vw)] max-h-[86vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">{{ scanResult.source.name }}:扫描结果</span>
+          <button type="button" class="icon-btn" title="关闭" @click="scanResult = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div v-if="!scanResult.scan.skills.length" class="hint">没有找到技能(需要包含 SKILL.md 的目录)。</div>
+        <div v-for="s in scanResult.scan.skills" :key="s.name" class="rounded-md border border-solid border-border p-2.5 flex flex-col gap-1">
+          <div class="flex items-center gap-2">
+            <input v-model="scanResult.selected[s.name]" type="checkbox" :disabled="s.status !== 'new' && s.status !== 'changed'" :aria-label="`导入 ${s.name}`">
+            <span class="font-mono font-500">{{ s.name }}</span>
+            <span class="badge bg-code" :class="STATUS[s.status]![1]">{{ STATUS[s.status]![0] }}</span>
+            <span class="hint">{{ s.path }} · {{ s.fileCount }} 个文件 · {{ fmtSize(s.size) }}</span>
+          </div>
+          <div class="hint line-clamp-2">{{ s.description }}</div>
+          <div v-if="s.reason" class="text-error">{{ s.reason }}</div>
+          <div v-for="w in s.warnings" :key="w" class="text-warning">{{ w }}</div>
+          <div v-if="s.status === 'changed' || s.status === 'new'" class="hint">
+            <template v-if="s.changes.added.length">新增 {{ s.changes.added.length }}</template>
+            <template v-if="s.changes.modified.length"> · 修改 {{ s.changes.modified.length }}</template>
+            <template v-if="s.changes.removed.length"> · 删除 {{ s.changes.removed.length }}</template>
+            <button type="button" class="ml-2 border-none bg-transparent p-0 cursor-pointer text-link" @click="scanResult.open[s.name] = !scanResult.open[s.name]">{{ scanResult.open[s.name] ? '收起' : '查看文件' }}</button>
+          </div>
+          <div v-if="scanResult.open[s.name]" class="font-mono text-[11.5px] flex flex-col pl-6">
+            <span v-for="p in s.changes.added" :key="`a${p}`" class="text-success">+ {{ p }}</span>
+            <span v-for="p in s.changes.modified" :key="`m${p}`" class="text-link">~ {{ p }}</span>
+            <span v-for="p in s.changes.removed" :key="`r${p}`" class="text-error">- {{ p }}</span>
+          </div>
+        </div>
+        <div v-if="scanResult.scan.missing.length" class="text-warning">来源中已不存在:{{ scanResult.scan.missing.join('、') }}(保留;不需要时可以在左侧删除)</div>
+        <div v-if="scanResult.scan.skipped.length" class="hint">跳过的条目:{{ scanResult.scan.skipped.join('、') }}</div>
+        <div class="hint">导入后立即发布为新版本;导入的技能只读,与来源保持一致。</div>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="scanResult = null">关闭</button>
+          <button type="button" class="btn-primary" :disabled="!!busy || !Object.values(scanResult.selected).some(Boolean)" @click="doImport">导入所选</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 版本历史 -->
+    <div v-if="showVersions && current" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="showVersions = false">
+      <div class="w-[min(620px,92vw)] max-h-[82vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-2 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">{{ current.name }} 的版本</span>
+          <button type="button" class="icon-btn" title="关闭" @click="showVersions = false"><span class="codicon codicon-close" /></button>
+        </div>
+        <div v-for="v in current.versions" :key="v.version" class="flex items-start gap-3 py-2 border-t border-t-solid border-border first:border-t-0">
+          <div class="flex-1 min-w-0">
+            <div class="flex items-center gap-2">
+              <span class="font-600">v{{ v.version }}</span>
+              <span class="badge bg-code">{{ REASON[v.reason] }}</span>
+              <span v-if="v.version === current.publishedVersion" class="badge bg-code text-success">生效中</span>
+              <span class="hint">{{ formatTime(v.createdAt) }} · {{ v.fileCount }} 个文件 · {{ fmtSize(v.size) }}</span>
+            </div>
+            <div class="hint">
+              <template v-if="v.changed.added.length">新增 {{ v.changed.added.join('、') }} </template>
+              <template v-if="v.changed.modified.length">修改 {{ v.changed.modified.join('、') }} </template>
+              <template v-if="v.changed.removed.length">删除 {{ v.changed.removed.join('、') }}</template>
+            </div>
+          </div>
+          <button v-if="v.version > 1" type="button" class="btn-ghost flex-none" @click="showDiff(v.version - 1, v.version, `v${v.version - 1} → v${v.version}`)">对比上一版</button>
+          <a :href="admin.customSkills.exportUrl(current.id, v.version)" class="btn-ghost flex-none no-underline">导出</a>
+          <button v-if="v.version !== current.publishedVersion && v.version === current.latestVersion" type="button" class="btn-secondary flex-none" @click="publishVersion(v.version)">发布</button>
+          <button v-if="v.version !== current.latestVersion" type="button" class="btn-secondary flex-none" @click="restore(v.version)">恢复</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 差异 -->
+    <div v-if="diff" class="fixed inset-0 z-50 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="diff = null">
+      <div class="w-[min(900px,94vw)] max-h-[86vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2">
+          <span class="font-600 text-[14px] flex-1">差异:{{ diff.title }}</span>
+          <button type="button" class="icon-btn" title="关闭" @click="diff = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div v-if="!diff.files.length" class="hint">没有差异</div>
+        <div v-for="f in diff.files" :key="f.path" class="flex flex-col gap-1">
+          <div class="flex items-center gap-2">
+            <span class="badge bg-code" :class="f.status === 'added' ? 'text-success' : f.status === 'removed' ? 'text-error' : 'text-link'">{{ { added: '新增', removed: '删除', modified: '修改' }[f.status] }}</span>
+            <span class="font-mono">{{ f.path }}</span>
+          </div>
+          <div v-if="f.binary" class="hint pl-2">二进制文件</div>
+          <pre v-else-if="f.patch" class="m-0 p-2 rounded-md bg-code font-mono text-[11.5px] overflow-x-auto"><span v-for="(l, i) in f.patch.split('\n').slice(4)" :key="i" class="block" :class="lineClass(l)">{{ l || ' ' }}</span></pre>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 42 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
@@ -54,6 +54,47 @@ export const admin = {
     install: (name: string) => req<CodexPluginInstallResultDto>('POST', `/codex-plugins/${encodeURIComponent(name)}/install`),
     uninstall: (name: string) => req<{ ok: true }>('POST', `/codex-plugins/${encodeURIComponent(name)}/uninstall`),
   },
+  customSkills: {
+    list: () => req<CustomSkillDto[]>('GET', '/custom-skills'),
+    create: (name: string, description: string) => req<CustomSkillDetailDto>('POST', '/custom-skills', { name, description }),
+    get: (id: string) => req<CustomSkillDetailDto>('GET', `/custom-skills/${id}`),
+    setFlags: (id: string, flags: { enabled?: boolean; includeInAll?: boolean }) => req<CustomSkillDetailDto>('PATCH', `/custom-skills/${id}`, flags),
+    remove: (id: string) => req<void>('DELETE', `/custom-skills/${id}`),
+    fileUrl: (id: string, path: string, version?: number) => `/admin/v1/custom-skills/${id}/file?path=${encodeURIComponent(path)}${version === undefined ? '' : `&version=${version}`}`,
+    readText: async (id: string, path: string, version?: number) => {
+      const r = await fetch(`/admin/v1/custom-skills/${id}/file?path=${encodeURIComponent(path)}${version === undefined ? '' : `&version=${version}`}`, { credentials: 'same-origin' })
+      if (!r.ok) throw new Error((await r.json().catch(() => null))?.error?.message ?? `HTTP ${r.status}`)
+      return r.text()
+    },
+    writeText: (id: string, path: string, content: string) => req<CustomSkillDetailDto>('PUT', `/custom-skills/${id}/file?path=${encodeURIComponent(path)}`, { content }),
+    upload: (id: string, path: string, file: File) => {
+      const form = new FormData()
+      form.append('file', file, file.name)
+      return req<CustomSkillDetailDto>('PUT', `/custom-skills/${id}/file?path=${encodeURIComponent(path)}`, form)
+    },
+    deleteFile: (id: string, path: string) => req<CustomSkillDetailDto>('DELETE', `/custom-skills/${id}/file?path=${encodeURIComponent(path)}`),
+    move: (id: string, from: string, to: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/move`, { from, to }),
+    discard: (id: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/discard`),
+    save: (id: string, baseVersion: number, publish: boolean) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/save`, { baseVersion, publish }),
+    publish: (id: string, version: number) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/publish`, { version }),
+    restore: (id: string, version: number) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/versions/${version}/restore`),
+    diff: (id: string, from: number | 'working', to: number | 'working') => req<FileDiffDto[]>('GET', `/custom-skills/${id}/diff?from=${from}&to=${to}`),
+    exportUrl: (id: string, version?: number) => `/admin/v1/custom-skills/${id}/export${version === undefined ? '' : `?version=${version}`}`,
+    detach: (id: string) => req<CustomSkillDetailDto>('POST', `/custom-skills/${id}/detach`),
+  },
+  skillSources: {
+    list: () => req<SkillSourceDto[]>('GET', '/skill-sources'),
+    create: (input: SkillSourceInput) => req<SkillSourceDto>('POST', '/skill-sources', input),
+    update: (id: string, input: SkillSourceInput) => req<SkillSourceDto>('PUT', `/skill-sources/${id}`, input),
+    remove: (id: string) => req<void>('DELETE', `/skill-sources/${id}`),
+    scan: (id: string) => req<SkillScanDto>('POST', `/skill-sources/${id}/scan`),
+    import: (id: string, skills: string[], digest: string) => req<{ imported: { name: string; version: number; created: boolean }[]; scan: SkillScanDto }>('POST', `/skill-sources/${id}/import`, { skills, digest }),
+    upload: (file: File) => {
+      const form = new FormData()
+      form.append('file', file, file.name)
+      return req<{ source: SkillSourceDto; scan: SkillScanDto }>('POST', '/skill-sources/upload', form)
+    },
+  },
   mcp: {
     state: () => req<McpStateDto>('GET', '/mcp'),
     create: (input: McpServerInput) => req<McpServerDto>('POST', '/mcp/servers', input),

+ 5 - 3
apps/admin/src/views/PluginsView.vue

@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
 import type {
   AvailablePluginDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
 } from '@agent-server/shared'
+import CustomSkillsPanel from '../components/CustomSkillsPanel.vue'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
@@ -10,7 +11,7 @@ import { formatTime, toast } from '../lib/ui'
 const OFFICIAL_SOURCE = 'anthropics/skills'
 
 // 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
-const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community'>('claude')
+const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community' | 'custom'>('claude')
 const codex = ref<CodexSkillCatalogDto | null>(null)
 const codexBusy = ref<string | null>(null)
 const codexSearch = ref('')
@@ -250,12 +251,13 @@ onMounted(() => {
     </PageHeader>
     <div class="flex-none flex gap-1 px-6 border-b border-b-solid border-border">
       <button
-        v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['codex-plugins', 'Codex · OpenAI 插件'], ['community', '第三方技能 · 两个引擎']] as const)" :key="t[0]" type="button"
+        v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['codex-plugins', 'Codex · OpenAI 插件'], ['community', '第三方技能 · 两个引擎'], ['custom', '自定义技能 · 两个引擎']] as const)" :key="t[0]" type="button"
         class="h-9 px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-2"
         :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
       >{{ t[1] }}</button>
     </div>
-    <div class="flex-1 overflow-y-auto">
+    <CustomSkillsPanel v-if="tab === 'custom'" />
+    <div v-else class="flex-1 overflow-y-auto">
       <!-- Codex 插件:openai-curated 目录,只在 Playground 中使用 -->
       <div v-if="tab === 'codex-plugins'" class="max-w-240 p-6 flex flex-col gap-5">
         <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">

+ 4 - 3
apps/admin/src/views/ProfilesView.vue

@@ -87,7 +87,7 @@ watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.k
 const skillsAll = computed({
   get: () => draft.value?.skills === 'all',
   // 取消"全部"时保留原来全部启用的技能;第三方技能不在"全部"之内
-  set: v => { if (draft.value) draft.value.skills = v ? 'all' : skills.value.filter(s => s.source !== 'community').map(s => s.id) },
+  set: v => { if (draft.value) draft.value.skills = v ? 'all' : skills.value.filter(s => !s.optIn).map(s => s.id) },
 })
 // 历史数据中不带前缀的技能名指本地技能目录
 const skillSelected = (s: SkillInfo) => Array.isArray(draft.value?.skills) && (draft.value!.skills.includes(s.id) || (s.source === 'local' && draft.value!.skills.includes(s.name)))
@@ -216,16 +216,17 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
 
           <section class="flex flex-col gap-2">
             <div class="label m-0">技能</div>
-            <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用全部可用技能(第三方技能除外,需取消此项后单独勾选)</label>
+            <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用全部可用技能(第三方技能与自定义技能除外,需取消此项后单独勾选)</label>
             <div class="flex flex-col gap-1">
               <label v-for="s in skills" :key="s.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': skillsAll }">
-                <input type="checkbox" class="mt-1" :checked="(skillsAll && s.source !== 'community') || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
+                <input type="checkbox" class="mt-1" :checked="(skillsAll && !s.optIn) || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
                 <span class="min-w-0">
                   <span class="font-mono text-[12px]">{{ s.id }}</span>
                   <span v-if="s.license === 'restricted'" class="badge bg-code text-warning ml-1">专有许可 · 仅 Claude</span>
                   <span v-if="s.source === 'official'" class="badge bg-code ml-1">OpenAI 官方</span>
                   <span v-if="s.source === 'system'" class="badge bg-code ml-1">Codex 自带</span>
                   <span v-if="s.source === 'community'" class="badge bg-code text-link ml-1">第三方</span>
+                  <span v-if="s.source === 'custom'" class="badge bg-code text-accent ml-1">自定义</span>
                   <span class="block hint truncate">{{ s.description }}</span>
                 </span>
               </label>

+ 41 - 0
apps/server/drizzle/0005_custom_skills.sql

@@ -0,0 +1,41 @@
+CREATE TABLE `custom_skill_versions` (
+	`skill_id` text NOT NULL,
+	`version` integer NOT NULL,
+	`reason` text NOT NULL,
+	`files` text NOT NULL,
+	`changed` text NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	PRIMARY KEY(`skill_id`, `version`)
+);
+--> statement-breakpoint
+CREATE TABLE `custom_skills` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`origin` text NOT NULL,
+	`source_id` text,
+	`enabled` integer DEFAULT true NOT NULL,
+	`include_in_all` integer DEFAULT false NOT NULL,
+	`published_version` integer,
+	`latest_version` integer DEFAULT 0 NOT NULL,
+	`working_files` text DEFAULT '{}' NOT NULL,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `custom_skills_name_unique` ON `custom_skills` (`name`);--> statement-breakpoint
+CREATE TABLE `skill_sources` (
+	`id` text PRIMARY KEY NOT NULL,
+	`name` text NOT NULL,
+	`description` text DEFAULT '' NOT NULL,
+	`kind` text NOT NULL,
+	`path` text NOT NULL,
+	`sync_mode` text DEFAULT 'manual' NOT NULL,
+	`interval_min` integer DEFAULT 10 NOT NULL,
+	`last_sync_at` text,
+	`last_sync_digest` text,
+	`status` text DEFAULT 'never' NOT NULL,
+	`last_error` text,
+	`created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL
+);

+ 1523 - 0
apps/server/drizzle/meta/0005_snapshot.json

@@ -0,0 +1,1523 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "6ffa15f1-3c63-4b6e-9a17-f5cddb1357a4",
+  "prevId": "200efeae-003d-487f-852a-e9c4403fd3c4",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_sources": {
+      "name": "skill_sources",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -36,6 +36,13 @@
       "when": 1792772473773,
       "tag": "0004_mcp_servers",
       "breakpoints": true
+    },
+    {
+      "idx": 5,
+      "version": "6",
+      "when": 1792938535790,
+      "tag": "0005_custom_skills",
+      "breakpoints": true
     }
   ]
 }

+ 2 - 0
apps/server/package.json

@@ -20,8 +20,10 @@
     "@modelcontextprotocol/sdk": "1.32.1",
     "@openai/codex": "0.164.0",
     "better-sqlite3": "^13.0.3",
+    "diff": "^8.0.2",
     "drizzle-orm": "^0.45.4",
     "fastify": "^5.12.7",
+    "fflate": "^0.8.2",
     "mime-types": "^3.0.2",
     "p-queue": "^10.0.0",
     "picomatch": "^4.0.7",

+ 1 - 1
apps/server/scripts/try-claude-adapter.ts

@@ -16,7 +16,7 @@ const ac = new AbortController()
 const res = await new ClaudeProvider().run({
   sessionId: 'ses_test', runId: 'run_test', model: 'haiku', permissionMode: mode as PermissionMode,
   providerSessionId: null, workspaceDir: ws, systemPromptAppend: '', allowedTools: [], disallowedTools: [], skills: 'all',
-  plugins: [], codexPlugins: false, gateway: { url: 'http://127.0.0.1:8788/mcp', token: 'none' }, input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
+  plugins: [], codexPlugins: false, skillsExcludedFromAll: [], gateway: { url: 'http://127.0.0.1:8788/mcp', token: 'none' }, input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
   emit: (type, payload) => {
     if (type === 'message.delta' || type === 'reasoning.delta') return
     console.log(`[event] ${type}`, JSON.stringify(payload).slice(0, Number(process.env.EVENT_MAX ?? 220)))

+ 9 - 2
apps/server/src/app.ts

@@ -23,6 +23,8 @@ import { UsageService } from './services/usage'
 import { PdfRenderer } from './api-reference/pdf'
 import { McpUpstream } from './mcp-gateway/upstream'
 import { McpServerService } from './services/mcp-servers'
+import { CustomSkillService } from './custom-skills/service'
+import { SkillSourceService } from './custom-skills/sources'
 import { BrowserTokenService } from './services/browser-tokens'
 
 export interface AppLogger {
@@ -48,6 +50,11 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const apiPdf = new PdfRenderer()
   const mcpUpstream = new McpUpstream()
   const mcpServers = new McpServerService(db, mcpUpstream, () => settings.get(), summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
+  const adminAudit = (summary: string, detail?: unknown) => audit.log({ actor: 'admin', kind: 'admin', summary, detail })
+  const customSkills = new CustomSkillService(db, adminAudit)
+  // 按数据库重建生效目录(缺失或被改动的技能目录重新发布)
+  customSkills.reconcile(msg => log.warn({}, msg))
+  const skillSources = new SkillSourceService(db, customSkills, () => settings.get(), adminAudit)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -62,9 +69,9 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     summary => audit.log({ actor: 'admin', kind: 'admin', summary }),
   )
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, gatewayTokens, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, mcpServers, customSkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 5 - 0
apps/server/src/config.ts

@@ -42,6 +42,11 @@ export const dbPath = () => path.join(config.dataDir, 'data', 'agent.db')
 /** 第三方技能目录(Claude 插件格式,两个引擎共用,ADR-0017) */
 export const communitySkillsDir = () => path.join(config.dataDir, 'data', 'community-skills')
 
+/** 自定义技能:生效目录(Claude 插件格式,两个引擎共用)、按内容寻址的文件库、上传的 zip 解压目录(docs/design/12-custom-skills.md) */
+export const customSkillsDir = () => path.join(config.dataDir, 'data', 'custom-skills')
+export const customSkillStoreDir = () => path.join(config.dataDir, 'data', 'custom-skill-store')
+export const skillUploadsDir = () => path.join(config.dataDir, 'data', 'skill-uploads')
+
 function parseTrustProxy(v: string | undefined): boolean | string[] {
   if (!v || v === 'false') return false
   if (v === 'true') return true

+ 503 - 0
apps/server/src/custom-skills/service.ts

@@ -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
+  }
+}

+ 365 - 0
apps/server/src/custom-skills/sources.ts

@@ -0,0 +1,365 @@
+// 技能源(docs/design/12-custom-skills.md §3.2 / FR-1):服务器目录或上传的 zip。
+// 扫描 → 管理员审阅差异 → 按扫描摘要导入(摘要不一致时拒绝,避免导入未审阅的内容);自动同步定时扫描并导入有变化的技能。
+// 只复制普通文件,跳过符号链接;zip 按实际解压字节数限制大小,拒绝越出解压目录的路径
+import fs from 'node:fs'
+import path from 'node:path'
+import { eq } from 'drizzle-orm'
+import { Unzip, UnzipInflate } from 'fflate'
+import type { AppSettings, SkillScanDto, SkillScanItemDto, SkillSourceDto, SkillSourceInput } from '@agent-server/shared'
+import { config, skillUploadsDir } from '../config'
+import type { DB } from '../db'
+import { customSkills, skillSources, type FileMap } from '../db/schema'
+import { AppError, badRequest, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import { frontmatter } from '../lib/skill-files'
+import { isWithin, realpathLoose } from '../lib/paths'
+import { globalDenyList } from '../services/access-policy'
+import { checkSkillMd, type CustomSkillService } from './service'
+import { changesBetween, checkSkillLimits, LIMITS, listRegularFiles, mapDigest, NAME_RE, normalizeRelPath, safeEntries, sha256, type ScannedFile } from './store'
+
+type SourceRow = typeof skillSources.$inferSelect
+
+interface ScannedSkill {
+  item: SkillScanItemDto
+  files: ScannedFile[]
+  map: FileMap
+}
+
+export class SkillSourceService {
+  private uploadSkipped = new Map<string, string[]>()
+  private timer: NodeJS.Timeout | null = null
+  private syncing = new Set<string>()
+
+  constructor(
+    private db: DB,
+    private skills: CustomSkillService,
+    private settings: () => AppSettings,
+    private audit: (summary: string, detail?: unknown) => void,
+  ) {}
+
+  /** 自动同步:每分钟检查一次到期的技能源 */
+  start() {
+    this.timer = setInterval(() => void this.autoSyncDue().catch(() => {}), 60_000)
+    this.timer.unref()
+  }
+
+  stop() {
+    if (this.timer) clearInterval(this.timer)
+  }
+
+  list(): SkillSourceDto[] {
+    const bySource = new Map<string, string[]>()
+    for (const s of this.db.select({ name: customSkills.name, sourceId: customSkills.sourceId }).from(customSkills).all()) {
+      if (s.sourceId) bySource.set(s.sourceId, [...(bySource.get(s.sourceId) ?? []), s.name])
+    }
+    return this.db.select().from(skillSources).orderBy(skillSources.createdAt).all().map(r => ({
+      id: r.id, name: r.name, description: r.description, kind: r.kind, path: r.kind === 'upload' ? '' : r.path,
+      syncMode: r.syncMode, intervalMin: r.intervalMin, lastSyncAt: r.lastSyncAt, status: r.status, lastError: r.lastError,
+      skillNames: (bySource.get(r.id) ?? []).sort(), createdAt: r.createdAt,
+    }))
+  }
+
+  create(input: SkillSourceInput): SkillSourceDto {
+    const data = this.validate(input)
+    const id = newId('src')
+    this.db.insert(skillSources).values({ id, kind: 'directory', ...data }).run()
+    this.audit(`登记技能源 ${data.name}(${data.path})`)
+    return this.get(id)
+  }
+
+  update(id: string, input: SkillSourceInput): SkillSourceDto {
+    const r = this.row(id)
+    const data = r.kind === 'upload' ? { ...this.validateMeta(input), path: r.path, syncMode: 'manual' as const } : this.validate(input)
+    this.db.update(skillSources).set({ ...data, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
+    this.audit(`修改技能源 ${data.name}`)
+    return this.get(id)
+  }
+
+  /** 删除技能源:从它导入的技能保留,转为可编辑(与来源脱钩) */
+  delete(id: string) {
+    const r = this.row(id)
+    const imported = this.db.select().from(customSkills).where(eq(customSkills.sourceId, id)).all()
+    for (const s of imported) this.db.update(customSkills).set({ origin: 'authored', sourceId: null, updatedAt: nowIso() }).where(eq(customSkills.id, s.id)).run()
+    this.db.delete(skillSources).where(eq(skillSources.id, id)).run()
+    if (r.kind === 'upload') fs.rmSync(path.join(skillUploadsDir(), id), { recursive: true, force: true })
+    this.uploadSkipped.delete(id)
+    this.audit(`删除技能源 ${r.name}${imported.length ? `(${imported.map(s => s.name).join('、')} 转为可编辑)` : ''}`)
+  }
+
+  scan(id: string): SkillScanDto {
+    const r = this.row(id)
+    try {
+      return this.toDto(r, this.scanRow(r))
+    } catch (err) {
+      this.db.update(skillSources).set({ status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, id)).run()
+      throw err
+    }
+  }
+
+  /** 按扫描结果导入选中的技能;扫描摘要变化时拒绝 */
+  import(id: string, names: string[], digest: string): { imported: { name: string; version: number; created: boolean }[]; scan: SkillScanDto } {
+    const r = this.row(id)
+    const scanned = this.scanRow(r)
+    if (this.digest(scanned) !== digest) throw new AppError(409, 'scan_changed', '来源内容在扫描后发生了变化,请重新扫描并确认')
+    const imported = this.importScanned(r, scanned.skills.filter(s => names.includes(s.item.name)), 'manual')
+    return { imported, scan: this.toDto(this.row(id), this.scanRow(this.row(id))) }
+  }
+
+  /** 上传 zip:解压到数据目录,登记为一次性的技能源,返回扫描结果 */
+  upload(filename: string, data: Buffer): { source: SkillSourceDto; scan: SkillScanDto } {
+    if (data.length > LIMITS.zipBytes) throw badRequest(`zip 文件不能超过 ${LIMITS.zipBytes / 1048576} MB`)
+    const id = newId('src')
+    const dir = path.join(skillUploadsDir(), id)
+    const skipped = extractZip(data, dir)
+    this.uploadSkipped.set(id, skipped)
+    const name = path.basename(filename || 'upload.zip').slice(0, 100)
+    this.db.insert(skillSources).values({ id, name, description: '', kind: 'upload', path: dir, syncMode: 'manual' }).run()
+    this.audit(`上传技能包 ${name}(${(data.length / 1024).toFixed(1)} KB,sha256 ${sha256(data).slice(0, 16)}…)${skipped.length ? `,跳过 ${skipped.length} 个不安全的条目` : ''}`, { skipped })
+    return { source: this.get(id), scan: this.scan(id) }
+  }
+
+  // ---- 内部 ----
+
+  private get(id: string): SkillSourceDto {
+    const s = this.list().find(x => x.id === id)
+    if (!s) throw notFound('技能源')
+    return s
+  }
+
+  private row(id: string): SourceRow {
+    const r = this.db.select().from(skillSources).where(eq(skillSources.id, id)).get()
+    if (!r) throw notFound('技能源')
+    return r
+  }
+
+  private validateMeta(input: SkillSourceInput) {
+    const name = input.name?.trim()
+    if (!name || name.length > 60) throw badRequest('请填写名称(不超过 60 个字)')
+    const intervalMin = Math.round(Number(input.intervalMin ?? 10))
+    if (!(intervalMin >= 1 && intervalMin <= 1440)) throw badRequest('同步间隔应在 1 ~ 1440 分钟之间')
+    return { name, description: (input.description ?? '').slice(0, 500), intervalMin }
+  }
+
+  /** 目录:存在、服务进程可读、不是服务数据目录或会话工作区、不命中全局禁止项 */
+  private validate(input: SkillSourceInput) {
+    const meta = this.validateMeta(input)
+    const raw = input.path?.trim() ?? ''
+    if (!path.isAbsolute(raw)) throw badRequest('路径必须是绝对路径')
+    const real = realpathLoose(raw)
+    if (!fs.existsSync(real)) {
+      const hint = /^\/(home|root)\//.test(real) ? '。服务以专用账号运行并启用了 ProtectHome,看不到 home 下的目录:请放到 home 以外,或为服务配置绑定挂载' : ''
+      throw badRequest(`目录不存在或服务看不到:${raw}${hint}`)
+    }
+    if (!fs.statSync(real).isDirectory()) throw badRequest(`不是目录:${raw}`)
+    try {
+      fs.accessSync(real, fs.constants.R_OK | fs.constants.X_OK)
+    } catch {
+      throw badRequest(`服务没有读取权限:${raw}`)
+    }
+    const s = this.settings()
+    for (const p of [config.dataDir, s.paths.workspaceRoot]) {
+      if (isWithin(real, realpathLoose(p)) || isWithin(realpathLoose(p), real)) throw badRequest('不能使用服务数据目录或会话工作区')
+    }
+    const hit = globalDenyList(s).find(d => !d.includes('*') && (isWithin(real, d) || isWithin(d, real)))
+    if (hit) throw badRequest(`路径与全局禁止项冲突:${hit}`)
+    return { ...meta, path: real, syncMode: input.syncMode === 'auto' ? 'auto' as const : 'manual' as const }
+  }
+
+  private scanRow(r: SourceRow): { skills: ScannedSkill[]; missing: string[]; skipped: string[] } {
+    if (!fs.existsSync(r.path)) throw badRequest(`来源目录不存在或服务看不到:${r.path}`)
+    const skipped = [...(this.uploadSkipped.get(r.id) ?? [])]
+    const skills = detectSkillDirs(r.path).map(d => this.scanSkill(r, d, skipped))
+    const found = new Set(skills.map(s => s.item.name))
+    const missing = this.db.select({ name: customSkills.name }).from(customSkills).where(eq(customSkills.sourceId, r.id)).all()
+      .map(s => s.name).filter(n => !found.has(n)).sort()
+    return { skills, missing, skipped }
+  }
+
+  private scanSkill(r: SourceRow, d: { rel: string; abs: string }, skipped: string[]): ScannedSkill {
+    const local: string[] = []
+    const files = listRegularFiles(d.abs, local)
+    skipped.push(...local.map(x => `${d.rel === '.' ? '' : `${d.rel}/`}${x}`))
+    const map: FileMap = {}
+    const base = { path: d.rel, fileCount: files.length, size: files.reduce((n, f) => n + f.size, 0), warnings: [] as string[], changes: { added: [], modified: [], removed: [] } }
+    const md = files.find(f => f.rel === 'SKILL.md')
+    const fm = md ? frontmatter(fs.readFileSync(md.abs, 'utf8').slice(0, 20_000)) : {}
+    const name = fm.name && NAME_RE.test(fm.name) ? fm.name : path.basename(d.rel === '.' ? d.abs : d.rel)
+    const invalid = (reason: string): ScannedSkill => ({ item: { ...base, name, description: fm.description ?? '', status: 'invalid', reason }, files, map })
+    if (!NAME_RE.test(name)) return invalid(`技能名 ${name} 不符合要求(小写字母、数字、连字符,1-64 位)`)
+    for (const f of files) {
+      try {
+        normalizeRelPath(f.rel)
+      } catch {
+        return invalid(`文件路径不正确:${f.rel}`)
+      }
+      if (f.size > LIMITS.bytesPerFile) return invalid(`文件 ${f.rel} 超过 ${LIMITS.bytesPerFile / 1048576} MB`)
+    }
+    try {
+      checkSkillLimits(Object.fromEntries(files.map(f => [f.rel, { sha: '', size: f.size }])))
+    } catch (err) {
+      return invalid((err as Error).message)
+    }
+    for (const f of files) map[f.rel] = { sha: sha256(fs.readFileSync(f.abs)), size: f.size }
+    let description = ''
+    try {
+      const c = checkSkillMd(name, md ? fs.readFileSync(md.abs) : undefined, false)
+      description = c.description
+      base.warnings.push(...c.warnings)
+    } catch (err) {
+      return invalid((err as Error).message)
+    }
+    const existing = this.skills.latestFiles(name)
+    if (existing && existing.sourceId !== r.id) {
+      return { item: { ...base, name, description, status: 'conflict', reason: `已存在同名技能 ${name},且不是从这个来源导入的` }, files, map }
+    }
+    const changes = changesBetween(existing?.files ?? {}, map)
+    const status = !existing ? 'new' : mapDigest(existing.files) === mapDigest(map) ? 'unchanged' : 'changed'
+    return { item: { ...base, name, description, status, reason: null, changes }, files, map }
+  }
+
+  private digest(s: { skills: ScannedSkill[] }): string {
+    return sha256(JSON.stringify(s.skills.map(x => [x.item.name, x.item.status, mapDigest(x.map)]))).slice(0, 32)
+  }
+
+  private toDto(r: SourceRow, s: { skills: ScannedSkill[]; missing: string[]; skipped: string[] }): SkillScanDto {
+    return { sourceId: r.id, digest: this.digest(s), skills: s.skills.map(x => x.item), missing: s.missing, skipped: s.skipped }
+  }
+
+  private importScanned(r: SourceRow, selected: ScannedSkill[], mode: 'manual' | 'auto') {
+    const out: { name: string; version: number; created: boolean }[] = []
+    for (const s of selected) {
+      if (s.item.status !== 'new' && s.item.status !== 'changed') continue
+      // 按扫描时的摘要读取并核对内容
+      const map: FileMap = {}
+      for (const f of s.files) {
+        const stored = this.skills.store.put(fs.readFileSync(f.abs))
+        if (stored.sha !== s.map[f.rel]!.sha) throw new AppError(409, 'scan_changed', `${s.item.name}/${f.rel} 在导入过程中发生了变化,请重新扫描`)
+        map[f.rel] = stored
+      }
+      const res = this.skills.importFiles({ name: s.item.name, files: map, sourceId: r.id, description: s.item.description })
+      out.push({ name: s.item.name, version: res.version, created: res.created })
+    }
+    const now = nowIso()
+    this.db.update(skillSources).set({ lastSyncAt: now, status: 'ok', lastError: null, updatedAt: now }).where(eq(skillSources.id, r.id)).run()
+    if (out.length) {
+      this.audit(`${mode === 'auto' ? '自动同步' : '导入'}技能源 ${r.name}:${out.map(x => `${x.name}${x.created ? '(新)' : ''} v${x.version}`).join('、')}`,
+        selected.filter(s => out.some(o => o.name === s.item.name)).map(s => ({ name: s.item.name, changes: s.item.changes })))
+    }
+    return out
+  }
+
+  private async autoSyncDue() {
+    const now = Date.now()
+    for (const r of this.db.select().from(skillSources).all()) {
+      if (r.kind !== 'directory' || r.syncMode !== 'auto' || this.syncing.has(r.id)) continue
+      if (r.lastSyncAt && now - Date.parse(r.lastSyncAt) < r.intervalMin * 60_000) continue
+      this.syncing.add(r.id)
+      try {
+        const scanned = this.scanRow(r)
+        // 自动同步只导入新增与变化的技能;来源中已删除的技能不自动删除;同名冲突跳过
+        this.importScanned(r, scanned.skills, 'auto')
+      } catch (err) {
+        this.db.update(skillSources).set({ lastSyncAt: nowIso(), status: 'error', lastError: (err as Error).message, updatedAt: nowIso() }).where(eq(skillSources.id, r.id)).run()
+      } finally {
+        this.syncing.delete(r.id)
+      }
+    }
+  }
+
+  /** 测试用:立即执行一次自动同步检查 */
+  runAutoSyncNow() {
+    return this.autoSyncDue()
+  }
+}
+
+/**
+ * 在来源中找技能目录:单个技能(根目录有 SKILL.md)、插件格式(skills/<name>/SKILL.md)或技能集合(<name>/SKILL.md)。
+ * 根目录只有一个子目录时(zip 常见的顶层目录)先进入它
+ */
+export function detectSkillDirs(root: string): { rel: string; abs: string }[] {
+  const has = (d: string) => fs.existsSync(path.join(d, 'SKILL.md')) && fs.lstatSync(path.join(d, 'SKILL.md')).isFile()
+  if (has(root)) return [{ rel: '.', abs: root }]
+  const found: { rel: string; abs: string }[] = []
+  for (const parent of ['skills', '.']) {
+    const dir = path.join(root, parent)
+    for (const e of safeEntries(dir)) {
+      if (!e.isDirectory() || e.name.startsWith('.')) continue
+      const abs = path.join(dir, e.name)
+      const rel = parent === '.' ? e.name : `${parent}/${e.name}`
+      if (has(abs) && !found.some(f => f.rel === rel)) found.push({ rel, abs })
+    }
+  }
+  if (!found.length) {
+    const entries = safeEntries(root).filter(e => !e.name.startsWith('.'))
+    if (entries.length === 1 && entries[0]!.isDirectory()) {
+      const only = entries[0]!.name
+      return detectSkillDirs(path.join(root, only)).map(d => ({ rel: d.rel === '.' ? only : `${only}/${d.rel}`, abs: d.abs }))
+    }
+  }
+  return found.sort((a, b) => a.rel.localeCompare(b.rel))
+}
+
+/**
+ * 解压 zip:按实际解压出的字节数计算大小(不信任文件头中的声明,防压缩炸弹);
+ * 路径不安全的条目跳过并返回;写入只发生在 dir 之内
+ */
+export function extractZip(data: Buffer, dir: string): string[] {
+  const skipped: string[] = []
+  const files = new Map<string, Uint8Array[]>()
+  let total = 0
+  let entries = 0
+  let error: Error | null = null
+  const unzip = new Unzip(file => {
+    if (error) return
+    if (file.name.endsWith('/')) return
+    if (++entries > LIMITS.zipEntries) {
+      error = badRequest(`zip 中的文件超过 ${LIMITS.zipEntries} 个`)
+      return
+    }
+    let rel: string
+    try {
+      rel = normalizeRelPath(file.name)
+    } catch {
+      skipped.push(`${file.name}(路径不安全)`)
+      return
+    }
+    if (rel.split('/').some(s => s === '__MACOSX')) return
+    const chunks: Uint8Array[] = []
+    files.set(rel, chunks)
+    file.ondata = (err, chunk, final) => {
+      if (err) {
+        error = badRequest(`zip 解压失败:${err.message}`)
+        return
+      }
+      total += chunk.length
+      if (total > LIMITS.unzipBytes) {
+        error = badRequest(`zip 解压后超过 ${LIMITS.unzipBytes / 1048576} MB`)
+        file.terminate()
+        return
+      }
+      chunks.push(chunk)
+      void final
+    }
+    file.start()
+  })
+  unzip.register(UnzipInflate)
+  try {
+    unzip.push(new Uint8Array(data), true)
+  } catch (err) {
+    throw badRequest(`不是有效的 zip 文件:${(err as Error).message}`)
+  }
+  if (error) throw error
+  if (!files.size) throw badRequest('zip 中没有文件')
+  fs.mkdirSync(dir, { recursive: true })
+  const root = path.resolve(dir)
+  for (const [rel, chunks] of files) {
+    const target = path.resolve(root, ...rel.split('/'))
+    if (!isWithin(target, root)) {
+      skipped.push(`${rel}(路径不安全)`)
+      continue
+    }
+    fs.mkdirSync(path.dirname(target), { recursive: true })
+    fs.writeFileSync(target, Buffer.concat(chunks))
+  }
+  return skipped
+}

+ 140 - 0
apps/server/src/custom-skills/store.ts

@@ -0,0 +1,140 @@
+// 自定义技能的文件库与文件工具:内容按 sha256 寻址,各版本共享相同内容(docs/design/12-custom-skills.md §6)
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import { badRequest } from '../lib/errors'
+import type { FileMap } from '../db/schema'
+
+export const LIMITS = {
+  filesPerSkill: 500,
+  bytesPerSkill: 20 * 1048576,
+  bytesPerFile: 10 * 1048576,
+  skillMd: 200_000,
+  zipBytes: 50 * 1048576,
+  /** 解压后的总大小(防压缩炸弹) */
+  unzipBytes: 200 * 1048576,
+  zipEntries: 5000,
+  skills: 200,
+}
+
+export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
+const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', '.idea', '.vscode'])
+const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db'])
+const TEXT_EXT = /\.(md|markdown|txt|json|jsonl|ya?ml|csv|tsv|py|js|mjs|cjs|ts|sh|bash|toml|ini|cfg|conf|xml|html?|css|sql|r|rb|go|java|tex|svg|env\.example)$/i
+
+/** 可以在后台以文本方式编辑的文件 */
+export const isTextPath = (p: string) => TEXT_EXT.test(p) || !path.basename(p).includes('.')
+
+export const sha256 = (data: Buffer | string) => crypto.createHash('sha256').update(data).digest('hex')
+
+/** 文件清单的摘要(顺序无关) */
+export const mapDigest = (m: FileMap) => sha256(JSON.stringify(Object.keys(m).sort().map(k => [k, m[k]!.sha])))
+
+/**
+ * 规范化技能内的相对路径:只允许普通的相对路径,拒绝绝对路径、..、反斜杠和控制字符。
+ * 返回以 / 分隔的路径
+ */
+export function normalizeRelPath(raw: string): string {
+  const p = raw.replace(/^\.\/+/, '')
+  if (!p || p.length > 300) throw badRequest(`文件路径不正确:${raw}`)
+  // eslint-disable-next-line no-control-regex
+  if (/[\x00-\x1f\x7f\\]/.test(p) || p.startsWith('/')) throw badRequest(`文件路径不正确:${raw}`)
+  const parts = p.split('/')
+  if (parts.some(s => !s || s === '.' || s === '..')) throw badRequest(`文件路径不正确:${raw}`)
+  return parts.join('/')
+}
+
+export function changesBetween(prev: FileMap, next: FileMap) {
+  const added = Object.keys(next).filter(p => !prev[p]).sort()
+  const removed = Object.keys(prev).filter(p => !next[p]).sort()
+  const modified = Object.keys(next).filter(p => prev[p] && prev[p]!.sha !== next[p]!.sha).sort()
+  return { added, modified, removed }
+}
+
+export function checkSkillLimits(m: FileMap) {
+  const files = Object.entries(m)
+  if (files.length > LIMITS.filesPerSkill) throw badRequest(`技能包含 ${files.length} 个文件,超过 ${LIMITS.filesPerSkill} 个的上限`)
+  const total = files.reduce((n, [, f]) => n + f.size, 0)
+  if (total > LIMITS.bytesPerSkill) throw badRequest(`技能大小超过 ${LIMITS.bytesPerSkill / 1048576} MB 上限`)
+  const big = files.find(([, f]) => f.size > LIMITS.bytesPerFile)
+  if (big) throw badRequest(`文件 ${big[0]} 超过 ${LIMITS.bytesPerFile / 1048576} MB 上限`)
+}
+
+/** 按内容寻址的文件库:<root>/<sha 前两位>/<sha> */
+export class BlobStore {
+  constructor(private root: string) {}
+
+  private file(sha: string) {
+    if (!/^[0-9a-f]{64}$/.test(sha)) throw new Error(`内容摘要不正确:${sha}`)
+    return path.join(this.root, sha.slice(0, 2), sha)
+  }
+
+  put(data: Buffer | string): { sha: string; size: number } {
+    const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
+    const sha = sha256(buf)
+    const f = this.file(sha)
+    if (!fs.existsSync(f)) {
+      fs.mkdirSync(path.dirname(f), { recursive: true })
+      const tmp = `${f}.${process.pid}.tmp`
+      fs.writeFileSync(tmp, buf)
+      fs.renameSync(tmp, f)
+    }
+    return { sha, size: buf.length }
+  }
+
+  get(sha: string): Buffer {
+    return fs.readFileSync(this.file(sha))
+  }
+
+  /** 删除不再被引用的内容 */
+  gc(referenced: Set<string>): number {
+    let removed = 0
+    for (const d of safeDir(this.root)) {
+      const dir = path.join(this.root, d)
+      for (const f of safeDir(dir)) {
+        if (/^[0-9a-f]{64}$/.test(f) && !referenced.has(f)) {
+          fs.rmSync(path.join(dir, f), { force: true })
+          removed++
+        }
+      }
+    }
+    return removed
+  }
+}
+
+export interface ScannedFile { rel: string; abs: string; size: number }
+
+/** 列出目录中的普通文件(跳过符号链接、特殊文件和常见的无关目录),symlinks 收集被跳过的路径 */
+export function listRegularFiles(dir: string, skipped: string[] = [], base = dir): ScannedFile[] {
+  const out: ScannedFile[] = []
+  for (const e of safeEntries(dir)) {
+    const abs = path.join(dir, e.name)
+    const rel = path.relative(base, abs).split(path.sep).join('/')
+    if (e.isSymbolicLink()) {
+      skipped.push(`${rel}(符号链接)`)
+    } else if (e.isDirectory()) {
+      if (!SKIP_DIRS.has(e.name)) out.push(...listRegularFiles(abs, skipped, base))
+    } else if (e.isFile()) {
+      if (!SKIP_FILES.has(e.name)) out.push({ rel, abs, size: fs.statSync(abs).size })
+    } else {
+      skipped.push(`${rel}(特殊文件)`)
+    }
+  }
+  return out
+}
+
+export function safeEntries(dir: string): fs.Dirent[] {
+  try {
+    return fs.readdirSync(dir, { withFileTypes: true })
+  } catch {
+    return []
+  }
+}
+
+function safeDir(dir: string): string[] {
+  try {
+    return fs.readdirSync(dir)
+  } catch {
+    return []
+  }
+}

+ 46 - 0
apps/server/src/db/schema.ts

@@ -200,3 +200,49 @@ export interface McpCachedTool {
   inputSchema: Record<string, unknown>
   readOnly: boolean
 }
+
+/** 文件清单:相对路径 → 内容摘要与大小(内容存放在按 sha256 寻址的文件库中) */
+export type FileMap = Record<string, { sha: string; size: number }>
+
+/** 自定义技能(docs/design/12-custom-skills.md)。运行时只读 custom-skills/skills/<name> 下的生效版本 */
+export const customSkills = sqliteTable('custom_skills', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull().unique(),
+  description: text('description').notNull().default(''),
+  origin: text('origin', { enum: ['authored', 'imported'] }).notNull(),
+  sourceId: text('source_id'),
+  enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
+  includeInAll: integer('include_in_all', { mode: 'boolean' }).notNull().default(false),
+  publishedVersion: integer('published_version'),
+  latestVersion: integer('latest_version').notNull().default(0),
+  /** 编辑中的文件(尚未保存为版本的改动) */
+  workingFiles: text('working_files', { mode: 'json' }).$type<FileMap>().notNull().default(sql`'{}'`),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})
+
+export const customSkillVersions = sqliteTable('custom_skill_versions', {
+  skillId: text('skill_id').notNull(),
+  version: integer('version').notNull(),
+  reason: text('reason', { enum: ['create', 'edit', 'import', 'sync', 'restore'] }).notNull(),
+  files: text('files', { mode: 'json' }).$type<FileMap>().notNull(),
+  changed: text('changed', { mode: 'json' }).$type<{ added: string[]; modified: string[]; removed: string[] }>().notNull(),
+  createdAt: createdAt(),
+}, t => [primaryKey({ columns: [t.skillId, t.version] })])
+
+/** 技能源:服务器目录或上传的 zip(解压在数据目录中) */
+export const skillSources = sqliteTable('skill_sources', {
+  id: text('id').primaryKey(),
+  name: text('name').notNull(),
+  description: text('description').notNull().default(''),
+  kind: text('kind', { enum: ['directory', 'upload'] }).notNull(),
+  path: text('path').notNull(),
+  syncMode: text('sync_mode', { enum: ['manual', 'auto'] }).notNull().default('manual'),
+  intervalMin: integer('interval_min').notNull().default(10),
+  lastSyncAt: text('last_sync_at'),
+  lastSyncDigest: text('last_sync_digest'),
+  status: text('status', { enum: ['never', 'ok', 'error'] }).notNull().default('never'),
+  lastError: text('last_error'),
+  createdAt: createdAt(),
+  updatedAt: updatedAt(),
+})

+ 95 - 5
apps/server/src/http/routes/admin.ts

@@ -1,9 +1,9 @@
 // 管理 API:仅后台登录态可访问
 import type { FastifyInstance, FastifyRequest } from 'fastify'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
-import { AppError, notFound } from '../../lib/errors'
+import { AppError, badRequest, notFound } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
 import { CodexProvider } from '../../providers/codex/adapter'
 import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
@@ -166,14 +166,17 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能;Codex = Codex 实际可见的技能
     g.get<{ Querystring: { engine?: string } }>('/skills', async req => {
-      if (req.query.engine !== 'codex') return [...await app.plugins.skills(), ...app.communitySkills.skillInfos()]
+      if (req.query.engine !== 'codex') return [...await app.plugins.skills(), ...app.communitySkills.skillInfos().map(s => ({ ...s, optIn: true })), ...app.customSkills.skillInfos()]
       const codex = app.providers.codex
       if (!(codex instanceof CodexProvider)) return []
       const infos = await codex.skillInfos(app.settings.get()).catch(() => [])
+      const excluded = new Set(app.customSkills.excludedFromAll())
       return infos.map(s => {
         const [prefix] = s.id.split(':')
-        const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin' : prefix === COMMUNITY_SKILL_PLUGIN ? 'community' : 'local'
-        return { id: s.id, name: s.id.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'official' || source === 'local' || source === 'community' ? 'open' : 'unknown' }
+        const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin'
+          : prefix === COMMUNITY_SKILL_PLUGIN ? 'community' : prefix === CUSTOM_SKILL_PLUGIN ? 'custom' : 'local'
+        const optIn = source === 'community' || (source === 'custom' && excluded.has(s.id))
+        return { id: s.id, name: s.id.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'plugin' || source === 'system' ? 'unknown' : 'open', optIn }
       })
     })
 
@@ -198,6 +201,93 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return app.mcpServers.test(b.input, b.id ?? null)
     })
 
+    // 自定义技能(docs/design/12-custom-skills.md)
+    type SkillParams = { Params: { id: string } }
+    type FileQuery = { Params: { id: string }; Querystring: { path?: string; version?: string } }
+    const optVersion = (v: string | undefined) => (v === undefined || v === '' ? undefined : Number(v))
+    g.get('/custom-skills', async () => app.customSkills.list())
+    g.post('/custom-skills', async (req, reply) => {
+      reply.code(201)
+      return app.customSkills.create(parse(z.object({ name: z.string(), description: z.string() }), req.body))
+    })
+    g.get<SkillParams>('/custom-skills/:id', async req => app.customSkills.detail(req.params.id))
+    g.patch<SkillParams>('/custom-skills/:id', async req =>
+      app.customSkills.setFlags(req.params.id, parse(z.object({ enabled: z.boolean().optional(), includeInAll: z.boolean().optional() }), req.body)))
+    g.delete<SkillParams>('/custom-skills/:id', async (req, reply) => {
+      app.customSkills.delete(req.params.id)
+      reply.code(204)
+    })
+    g.get<FileQuery>('/custom-skills/:id/file', async (req, reply) => {
+      const f = app.customSkills.readFile(req.params.id, req.query.path ?? '', optVersion(req.query.version))
+      reply.header('Content-Type', f.text ? 'text/plain; charset=utf-8' : 'application/octet-stream')
+      reply.header('Content-Disposition', `${f.text ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
+      reply.header('X-Content-Type-Options', 'nosniff')
+      return reply.send(f.data)
+    })
+    // 文本文件:JSON { content };二进制文件:multipart(字段 file)
+    g.put<FileQuery>('/custom-skills/:id/file', async req => {
+      if (req.isMultipart()) {
+        const part = await req.file({ limits: { fileSize: 10 * 1048576 } })
+        if (!part) throw badRequest('没有收到文件')
+        const data = await part.toBuffer()
+        if (part.file.truncated) throw new AppError(413, 'file_too_large', '单个文件不能超过 10 MB')
+        return app.customSkills.writeFile(req.params.id, req.query.path ?? part.filename, data)
+      }
+      const body = parse(z.object({ content: z.string() }), req.body)
+      return app.customSkills.writeFile(req.params.id, req.query.path ?? '', Buffer.from(body.content, 'utf8'))
+    })
+    g.delete<FileQuery>('/custom-skills/:id/file', async req => app.customSkills.deleteFile(req.params.id, req.query.path ?? ''))
+    g.post<SkillParams>('/custom-skills/:id/move', async req => {
+      const b = parse(z.object({ from: z.string(), to: z.string() }), req.body)
+      return app.customSkills.move(req.params.id, b.from, b.to)
+    })
+    g.post<SkillParams>('/custom-skills/:id/discard', async req => app.customSkills.discard(req.params.id))
+    g.post<SkillParams>('/custom-skills/:id/save', async req =>
+      app.customSkills.save(req.params.id, parse(z.object({ baseVersion: z.number().int(), publish: z.boolean() }), req.body)))
+    g.post<SkillParams>('/custom-skills/:id/publish', async req =>
+      app.customSkills.publish(req.params.id, parse(z.object({ version: z.number().int() }), req.body).version))
+    g.post<{ Params: { id: string; v: string } }>('/custom-skills/:id/versions/:v/restore', async req => app.customSkills.restore(req.params.id, Number(req.params.v)))
+    g.get<{ Params: { id: string }; Querystring: { from?: string; to?: string } }>('/custom-skills/:id/diff', async req => {
+      const v = (x: string | undefined) => (x === 'working' ? 'working' as const : Number(x))
+      return app.customSkills.diff(req.params.id, v(req.query.from), v(req.query.to))
+    })
+    g.get<FileQuery>('/custom-skills/:id/export', async (req, reply) => {
+      const z_ = app.customSkills.exportZip(req.params.id, optVersion(req.query.version))
+      reply.header('Content-Type', 'application/zip')
+      reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
+      return reply.send(z_.data)
+    })
+    g.post<SkillParams>('/custom-skills/:id/detach', async req => app.customSkills.detach(req.params.id))
+
+    // 技能源:服务器目录或上传的 zip
+    const sourceSchema = z.object({
+      name: z.string(), description: z.string().default(''), path: z.string().default(''),
+      syncMode: z.enum(['manual', 'auto']).default('manual'), intervalMin: z.number().default(10),
+    })
+    g.get('/skill-sources', async () => app.skillSources.list())
+    g.post('/skill-sources', async (req, reply) => {
+      reply.code(201)
+      return app.skillSources.create(parse(sourceSchema, req.body))
+    })
+    g.put<SkillParams>('/skill-sources/:id', async req => app.skillSources.update(req.params.id, parse(sourceSchema, req.body)))
+    g.delete<SkillParams>('/skill-sources/:id', async (req, reply) => {
+      app.skillSources.delete(req.params.id)
+      reply.code(204)
+    })
+    g.post<SkillParams>('/skill-sources/:id/scan', async req => app.skillSources.scan(req.params.id))
+    g.post<SkillParams>('/skill-sources/:id/import', async req => {
+      const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
+      return app.skillSources.import(req.params.id, b.skills, b.digest)
+    })
+    g.post('/skill-sources/upload', async (req, reply) => {
+      const part = await req.file({ limits: { fileSize: 50 * 1048576 } })
+      if (!part) throw badRequest('没有收到文件')
+      const data = await part.toBuffer()
+      if (part.file.truncated) throw new AppError(413, 'file_too_large', 'zip 文件不能超过 50 MB')
+      reply.code(201)
+      return app.skillSources.upload(part.filename, data)
+    })
+
     // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
     g.get('/community-skills', async () => app.communitySkills.list())
     g.get<{ Querystring: { q?: string } }>('/community-skills/search', async req => app.communitySkills.search(req.query.q ?? ''))

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 3 - 0
apps/server/src/main.ts

@@ -15,6 +15,7 @@ const shutdown = async (signal: string) => {
   logger.info({ signal }, 'shutting down')
   await server.close()
   await app.mcpUpstream.closeAll()
+  app.skillSources.stop()
   app.db.$client.close()
   process.exit(0)
 }
@@ -22,4 +23,6 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
 process.on('SIGTERM', () => void shutdown('SIGTERM'))
 
 await server.listen({ host: config.host, port: config.port })
+// 技能源自动同步
+app.skillSources.start()
 logger.info({ dataDir: config.dataDir }, `AgentServer 已启动:http://${config.host}:${config.port}`)

+ 3 - 2
apps/server/src/providers/claude/skills.ts

@@ -12,7 +12,8 @@ export const LOCAL_PLUGIN_FALLBACK = 'agent-skills'
 export interface PluginRef {
   name: string
   path: string
-  skills: { id: string; loadId: string }[]
+  /** optIn:单个技能需要在 Profile 中单独勾选(自定义技能可逐个设置) */
+  skills: { id: string; loadId: string; optIn?: boolean }[]
   /** 需要在 Profile 中单独勾选的插件(第三方技能),选择"全部技能"时不包含 */
   optIn?: boolean
 }
@@ -46,7 +47,7 @@ export function resolveSkills(selected: string[] | 'all', plugins: PluginRef[])
   const available = plugins.flatMap(p => p.skills)
   const local = plugins[0]
   const wantedIds = selected === 'all'
-    ? new Set(plugins.filter(p => !p.optIn).flatMap(p => p.skills.map(s => s.id)))
+    ? new Set(plugins.filter(p => !p.optIn).flatMap(p => p.skills.filter(s => !s.optIn).map(s => s.id)))
     : new Set(selected.map(s => (s.includes(':') || !local ? s : `${local.name}:${s}`)))
   const chosen = available.filter(s => wantedIds.has(s.id))
   // 只加载至少有一个技能被选中的插件

+ 4 - 3
apps/server/src/providers/codex/adapter.ts

@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'
 import fs from 'node:fs'
 import path from 'node:path'
 import type { AppSettings, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared'
-import { communitySkillsDir } from '../../config'
+import { communitySkillsDir, customSkillsDir } from '../../config'
 import { checkAccess } from '../../services/access-policy'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
 import { bundledCodexPath, codexHome } from './cli'
@@ -85,7 +85,8 @@ export class CodexProvider implements AgentProvider {
       })
       const init = await client.start(CLIENT_INFO) as { userAgent?: string }
       // 本地技能目录(自写技能)与第三方技能目录与 Claude 共用,作为额外技能根目录
-      const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), 'skills')].filter(d => fs.existsSync(d))
+      // 自定义技能目录在服务启动时创建,之后发布的技能 Codex 每次列技能时都会看到
+      const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), 'skills'), path.join(customSkillsDir(), 'skills')].filter(d => fs.existsSync(d))
       if (extraRoots.length) await client.request('skills/extraRoots/set', { extraRoots }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败'))
       // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本
       this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null
@@ -108,7 +109,7 @@ export class CodexProvider implements AgentProvider {
     })
     // 按 Profile 的技能选择禁用其余技能(只作用于本次对话)
     const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
-    const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins)
+    const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins, ctx.skillsExcludedFromAll)
     if (disabled.length) config.skills = { config: disabled }
     // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入
     config.mcp_servers = {

+ 34 - 4
apps/server/src/providers/codex/permissions.ts

@@ -1,10 +1,11 @@
 // 把合并后的文件访问策略映射为 Codex 的权限配置(permission profile),随 thread/start 的 config 传入。
 // 已验证(2026-10-23,codex 0.164):":root" = "deny" 可以做默认拒绝;Codex 的沙箱辅助程序是指向 codex 二进制的
 // 链接,位于 CODEX_HOME/tmp/arg0-linux,这两处必须可读,否则沙箱无法启动(docs/design/03-providers.md §6)
+import nodeFs from 'node:fs'
 import path from 'node:path'
 import type { AppSettings, PermissionMode } from '@agent-server/shared'
 import type { ResolvedAccess } from '../../services/access-policy'
-import { hasGlob, realpathLoose } from '../../lib/paths'
+import { hasGlob, isWithin, realpathLoose } from '../../lib/paths'
 
 export const PROFILE_NAME = 'agent_server'
 
@@ -59,14 +60,20 @@ export function buildThreadConfig(opts: {
   const workspace = realpathLoose(access.workspaceDir)
   for (const p of access.readable) if (p !== '/' && p !== workspace) fs[p] = 'read'
   for (const p of access.writable) if (p !== workspace) fs[p] = opts.permissionMode === 'readonly' ? 'read' : 'write'
-  // 宽松模式下屏蔽其他会话的工作区与服务数据目录(当前工作区更具体,仍然可写)
-  if (access.mode === 'broad') for (const p of access.softDeny) fs[p] = 'deny'
+  // 需要可读的路径(技能、运行环境、资料库等)。Codex 中父目录的 deny 会压过子路径的 read(已实测,codex 0.164;
+  // 子路径为 write 时不受影响),所以禁止一个包含这些路径的目录时,改为逐个禁止它的其他内容
+  const keepReadable = Object.entries(fs).filter(([k, v]) => v === 'read' && path.isAbsolute(k)).map(([k]) => k)
+  const deny = (dir: string) => {
+    for (const p of denyAround(dir, keepReadable)) fs[p] = 'deny'
+  }
+  // 宽松模式下屏蔽其他会话的工作区与服务数据目录(当前工作区更具体且可写,仍然可用)
+  if (access.mode === 'broad') for (const p of access.softDeny) deny(p)
 
   const workspaceRules: Record<string, Access> = { '.': opts.permissionMode === 'readonly' ? 'read' : 'write' }
   let hasGlobRule = false
   for (const entry of access.hardDeny) {
     if (path.isAbsolute(entry) && !hasGlob(entry)) {
-      fs[entry] = 'deny'
+      deny(entry)
     } else if (!path.isAbsolute(entry)) {
       // 相对通配(如 **/*.env)作用于工作区
       workspaceRules[entry] = 'deny'
@@ -104,3 +111,26 @@ export function buildThreadConfig(opts: {
     },
   }
 }
+
+/**
+ * 要禁止的目录中如果包含必须可读的路径,返回该目录下除通往这些路径之外的全部条目(逐层展开);否则返回目录本身。
+ * 目录不存在时返回自身(规则无害)
+ */
+export function denyAround(dir: string, keep: string[]): string[] {
+  const inner = keep.filter(k => k !== dir && isWithin(k, dir))
+  if (!inner.length) return [dir]
+  let entries: string[]
+  try {
+    entries = nodeFs.readdirSync(dir)
+  } catch {
+    return [dir]
+  }
+  const out: string[] = []
+  for (const name of entries) {
+    const p = path.join(dir, name)
+    if (inner.includes(p)) continue
+    if (inner.some(k => isWithin(k, p))) out.push(...denyAround(p, inner))
+    else out.push(p)
+  }
+  return out
+}

+ 9 - 4
apps/server/src/providers/codex/skills.ts

@@ -1,7 +1,7 @@
 // Codex 技能的展示 ID 与按 Profile 选择(与 Claude 侧的 providers/claude/skills.ts 对应)
 import fs from 'node:fs'
 import path from 'node:path'
-import { COMMUNITY_SKILL_PLUGIN } from '@agent-server/shared'
+import { COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN } from '@agent-server/shared'
 import type { SkillMetadata } from './protocol/v2'
 
 /** 服务场景下始终禁用的系统技能:会联网下载或写入 Codex 自身目录 */
@@ -17,7 +17,9 @@ export const ALWAYS_DISABLED = new Set(['skill-installer', 'skill-creator', 'plu
 export function skillDisplayId(s: Pick<SkillMetadata, 'name' | 'scope' | 'path'>, codexHome: string): string {
   if (s.scope === 'system') return `codex-system:${s.name}`
   // Codex 用 SKILL.md 的 name 命名(可能是 "Excel Analysis"),Claude 用目录名;第三方技能统一按目录名,两个引擎 ID 一致
-  if (s.name.startsWith(`${COMMUNITY_SKILL_PLUGIN}:`)) return `${COMMUNITY_SKILL_PLUGIN}:${path.basename(path.dirname(s.path))}`
+  for (const plugin of [COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN]) {
+    if (s.name.startsWith(`${plugin}:`)) return `${plugin}:${path.basename(path.dirname(s.path))}`
+  }
   if (s.name.includes(':')) return s.name
   const managedRoot = path.join(codexHome, 'skills')
   const dir = path.dirname(s.path)
@@ -29,14 +31,17 @@ export function skillDisplayId(s: Pick<SkillMetadata, 'name' | 'scope' | 'path'>
  * 计算需要在本次对话中禁用的技能(按路径选择);选择"全部技能"时第三方技能仍需单独勾选。
  * 远程插件带来的技能(pluginId 非空)不由 Profile 选择:开放插件时(Playground)全部保留,否则全部禁用
  */
-export function skillsToDisable(all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string, allowPlugins = false): { path: string; enabled: false }[] {
+export function skillsToDisable(
+  all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string, allowPlugins = false, excludedFromAll: string[] = [],
+): { path: string; enabled: false }[] {
+  const excluded = new Set(excludedFromAll)
   const wanted = selected === 'all' ? null : new Set(selected.map(s => (s.includes(':') ? s : `${localPlugin}:${s}`)))
   return all
     .filter(s => {
       if (ALWAYS_DISABLED.has(s.name)) return true
       if (s.pluginId) return !allowPlugins
       const id = skillDisplayId(s, codexHome)
-      return wanted === null ? id.startsWith(`${COMMUNITY_SKILL_PLUGIN}:`) : !wanted.has(id)
+      return wanted === null ? id.startsWith(`${COMMUNITY_SKILL_PLUGIN}:`) || excluded.has(id) : !wanted.has(id)
     })
     .map(s => ({ path: s.path, enabled: false as const }))
 }

+ 2 - 0
apps/server/src/providers/types.ts

@@ -22,6 +22,8 @@ export interface RunContext {
   allowedTools: string[]
   disallowedTools: string[]
   skills: string[] | 'all'
+  /** 选择"全部技能"时不包含的技能(需要单独勾选的自定义技能),Codex 据此禁用 */
+  skillsExcludedFromAll: string[]
   /** 通过 Claude Code 插件机制安装并启用的插件(只在 Claude 中加载) */
   plugins: PluginRef[]
   input: RunInput

+ 10 - 1
apps/server/src/services/access-policy.ts

@@ -11,7 +11,7 @@ import {
   type PathCheck,
   type ResolvedAccessDto,
 } from '@agent-server/shared'
-import { communitySkillsDir, config } from '../config'
+import { communitySkillsDir, config, customSkillsDir } from '../config'
 import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
 import { codexHome } from '../providers/codex/cli'
 
@@ -58,6 +58,8 @@ export function resolveAccess(opts: {
   readable.add(settings.paths.nodeModules)
   readable.add(settings.paths.skillsDir)
   readable.add(communitySkillsDir())
+  // 只开放生效目录;版本库(custom-skill-store)在服务数据目录中,不可见
+  readable.add(customSkillsDir())
 
   for (const id of opts.selectedKnowledgeRootIds) {
     const root = roots.get(id)
@@ -127,8 +129,15 @@ function mostSpecific(target: string, list: string[]): string | null {
   return best
 }
 
+/**
+ * 服务管理的技能生效目录(第三方、自定义):位于全局禁止的 {dataDir}/data 之内,但必须只读可见,
+ * 否则 Agent 读不到技能的参考文档、运行不了脚本。只放开这两个目录,data 下的其他内容(数据库、版本库、MCP 数据)仍然禁止
+ */
+export const managedSkillRoots = () => [communitySkillsDir(), customSkillsDir()].map(realpathLoose)
+
 export function checkAccess(access: ResolvedAccess, rawPath: string, op: AccessOp): AccessDecision {
   const target = realpathLoose(rawPath)
+  if (op === 'read' && managedSkillRoots().some(r => isWithin(target, r))) return { allowed: true }
   for (const entry of access.hardDeny) {
     if (matchesEntry(target, entry)) return { allowed: false, reason: `路径被禁止访问:${entry}` }
   }

+ 8 - 4
apps/server/src/services/runs.ts

@@ -19,6 +19,7 @@ import type { FileService } from './files'
 import type { InteractionService, NewApproval, NewQuestion } from './interactions'
 import type { CommunitySkillService } from './community-skills'
 import type { McpServerService } from './mcp-servers'
+import type { CustomSkillService } from '../custom-skills/service'
 import type { McpServerRow } from '../mcp-gateway/upstream'
 import type { PluginService } from './plugins'
 import type { UsageService } from './usage'
@@ -68,6 +69,7 @@ export class RunManager {
       plugins: PluginService
       communitySkills: CommunitySkillService
       mcpServers: McpServerService
+      customSkills: CustomSkillService
       gatewayTokens: RunTokenRegistry
       providers: Partial<Record<ProviderId, AgentProvider>>
     },
@@ -220,6 +222,7 @@ export class RunManager {
         skills: profile.skills,
         // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)。第三方技能 Codex 经 extraRoots 加载(ADR-0017)
         plugins: session.provider === 'claude' ? await this.claudePlugins() : [],
+        skillsExcludedFromAll: deps.customSkills.excludedFromAll(),
         // Codex 插件与应用连接器使用账号绑定的数据,只在后台 Playground(source = admin)中开放
         codexPlugins: session.provider === 'codex' && session.source === 'admin',
         input: await this.buildInput(session, text, attachmentIds),
@@ -238,7 +241,8 @@ export class RunManager {
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${session!.provider}`, sessionId, kind, summary, detail }),
         log,
         // 网址带上第三方 MCP 工具的版本:勾选或工具策略变化时,Codex 已加载的会话因配置不同而分叉,重新获取工具列表
-        gateway: { url: `${gatewayUrl()}?tools=${mcpToolsVersion(deps.mcpServers.rows(profile.mcpServerIds))}`, token: gatewayToken },
+        // 自定义技能的版本也放进网址:发布或启停后,Codex 已加载的会话同样会分叉,按新版本列出技能
+        gateway: { url: `${gatewayUrl()}?tools=${mcpToolsVersion(deps.mcpServers.rows(profile.mcpServerIds))}&skills=${deps.customSkills.fingerprint()}`, token: gatewayToken },
       })
 
       clearTimeout(scanTimer)
@@ -296,10 +300,10 @@ export class RunManager {
     }
   }
 
-  /** Claude 运行时加载的插件:已启用的插件 + 第三方技能目录 */
+  /** Claude 运行时加载的插件:已启用的插件 + 第三方技能 + 自定义技能 */
   private async claudePlugins() {
-    const community = this.deps.communitySkills.pluginRef()
-    return [...await this.deps.plugins.loadable().catch(() => []), ...(community ? [community] : [])]
+    const extra = [this.deps.communitySkills.pluginRef(), this.deps.customSkills.pluginRef()].filter(p => p !== null)
+    return [...await this.deps.plugins.loadable().catch(() => []), ...extra]
   }
 
   private async buildInput(session: SessionRow, text: string, attachmentIds: string[]): Promise<RunInput> {

+ 15 - 1
apps/server/test/codex.test.ts

@@ -1,3 +1,4 @@
+import os from 'node:os'
 import fs from 'node:fs'
 import path from 'node:path'
 import { describe, expect, it } from 'vitest'
@@ -23,7 +24,7 @@ function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask'
   let sessionId: string | null = null
   const ctx: RunContext = {
     sessionId: 's', runId: 'r', model: 'gpt-test', permissionMode: opts.mode ?? 'ask', providerSessionId: opts.providerSessionId ?? null,
-    workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [], codexPlugins: opts.codexPlugins ?? false,
+    workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [], codexPlugins: opts.codexPlugins ?? false, skillsExcludedFromAll: [],
     gateway: { url: 'http://127.0.0.1:9/mcp', token: 'rt_test' },
     input: { text, images: [], attachments: [] },
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: ['**/*.env'] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
@@ -184,3 +185,16 @@ describe('commandTitle', () => {
     expect(commandTitle('ls -la')).toBe('ls -la')
   })
 })
+
+describe('Codex 权限:父目录禁止、子路径可读', () => {
+  it('denyAround:逐层展开,只留下通往可读路径的目录', async () => {
+    const { denyAround } = await import('../src/providers/codex/permissions')
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 'deny-'))
+    for (const d of ['data/custom-skills/skills', 'data/community-skills', 'data/mcp', 'workspaces/a']) fs.mkdirSync(path.join(root, d), { recursive: true })
+    fs.writeFileSync(path.join(root, 'data', 'agent.db'), '')
+    const keep = [path.join(root, 'data', 'custom-skills'), path.join(root, 'data', 'community-skills')]
+    expect(denyAround(path.join(root, 'data'), keep).map(p => path.relative(root, p)).sort()).toEqual(['data/agent.db', 'data/mcp'])
+    expect(denyAround(root, keep).map(p => path.relative(root, p)).sort()).toEqual(['data/agent.db', 'data/mcp', 'workspaces'])
+    expect(denyAround(path.join(root, 'workspaces'), keep)).toEqual([path.join(root, 'workspaces')])
+  })
+})

+ 260 - 0
apps/server/test/custom-skills.test.ts

@@ -0,0 +1,260 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import pino from 'pino'
+import { strToU8, unzipSync, zipSync } from 'fflate'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { createApp, type App } from '../src/app'
+import { config, customSkillsDir, customSkillStoreDir } from '../src/config'
+import { LIMITS, normalizeRelPath } from '../src/custom-skills/store'
+import { resolveSkills } from '../src/providers/claude/skills'
+import { skillsToDisable } from '../src/providers/codex/skills'
+import { checkAccess, resolveAccess } from '../src/services/access-policy'
+
+let app: App
+const audits: string[] = []
+const live = (name: string, f = 'SKILL.md') => path.join(customSkillsDir(), 'skills', name, f)
+const md = (name: string, desc = `${name} 的说明:处理某类业务数据的流程与规范`) => `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n`
+
+/** 一个插件格式的技能源目录:skills/alpha、skills/beta(含指向 /etc/passwd 的符号链接) */
+function makeSourceDir(): string {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src-'))
+  for (const n of ['alpha', 'beta']) {
+    fs.mkdirSync(path.join(root, 'skills', n, 'references'), { recursive: true })
+    fs.writeFileSync(path.join(root, 'skills', n, 'SKILL.md'), md(n))
+    fs.writeFileSync(path.join(root, 'skills', n, 'references', 'guide.md'), `${n} guide v1\nline2\n`)
+  }
+  fs.symlinkSync('/etc/passwd', path.join(root, 'skills', 'beta', 'passwd'))
+  return root
+}
+
+beforeAll(() => {
+  app = createApp(pino({ level: 'silent' }), { providers: {} })
+  const orig = app.audit.log.bind(app.audit)
+  app.audit.log = (e: Parameters<typeof orig>[0]) => { audits.push(e.summary); return orig(e) }
+})
+afterAll(() => app.skillSources.stop())
+
+describe('自定义技能:目录技能源导入与同步', () => {
+  let srcDir: string
+  let sourceId: string
+
+  it('C-01 扫描出 2 个技能并导入;两个引擎的 ID 为 custom-skills:<name>;符号链接被跳过(C-03)', () => {
+    srcDir = makeSourceDir()
+    const s = app.skillSources.create({ name: '业务仓库', description: '', path: srcDir, syncMode: 'manual', intervalMin: 10 })
+    sourceId = s.id
+    const scan = app.skillSources.scan(sourceId)
+    expect(scan.skills.map(x => [x.name, x.status, x.path, x.fileCount])).toEqual([['alpha', 'new', 'skills/alpha', 2], ['beta', 'new', 'skills/beta', 2]])
+    expect(scan.skipped).toEqual(['skills/beta/passwd(符号链接)'])
+
+    const r = app.skillSources.import(sourceId, ['alpha', 'beta'], scan.digest)
+    expect(r.imported.map(x => [x.name, x.version, x.created])).toEqual([['alpha', 1, true], ['beta', 1, true]])
+    expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
+    expect(fs.existsSync(live('beta', 'passwd'))).toBe(false)
+    expect(app.customSkills.list().map(x => [x.name, x.origin, x.publishedVersion, x.sourceName])).toEqual([['alpha', 'imported', 1, '业务仓库'], ['beta', 'imported', 1, '业务仓库']])
+    expect(app.skillSources.list()[0]!.skillNames).toEqual(['alpha', 'beta'])
+    expect(app.customSkills.pluginRef()!.skills.map(s => [s.id, s.optIn])).toEqual([['custom-skills:alpha', true], ['custom-skills:beta', true]])
+  })
+
+  it('C-02 修改来源中的一个参考文件:同步只显示该文件的变化,导入后生效目录更新', () => {
+    fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'references', 'guide.md'), 'alpha guide v2\nline2\n')
+    const scan = app.skillSources.scan(sourceId)
+    const alpha = scan.skills.find(s => s.name === 'alpha')!
+    expect(alpha.status).toBe('changed')
+    expect(alpha.changes).toEqual({ added: [], modified: ['references/guide.md'], removed: [] })
+    expect(scan.skills.find(s => s.name === 'beta')!.status).toBe('unchanged')
+    // 扫描后来源又变了:拒绝导入未审阅的内容
+    fs.writeFileSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'), 'x')
+    expect(() => app.skillSources.import(sourceId, ['alpha'], scan.digest)).toThrow(/重新扫描/)
+    fs.rmSync(path.join(srcDir, 'skills', 'alpha', 'extra.md'))
+    const again = app.skillSources.scan(sourceId)
+    expect(app.skillSources.import(sourceId, ['alpha'], again.digest).imported).toEqual([{ name: 'alpha', version: 2, created: false }])
+    expect(fs.readFileSync(live('alpha', 'references/guide.md'), 'utf8')).toBe('alpha guide v2\nline2\n')
+    const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
+    expect(app.customSkills.diff(id, 1, 2)[0]).toMatchObject({ path: 'references/guide.md', status: 'modified' })
+    expect(app.customSkills.diff(id, 1, 2)[0]!.patch).toContain('-alpha guide v1')
+  })
+
+  it('来源中删除的技能只提示,不自动删除;同名冲突拒绝;导入的技能只读,转为可编辑后可改', () => {
+    fs.rmSync(path.join(srcDir, 'skills', 'beta'), { recursive: true })
+    expect(app.skillSources.scan(sourceId).missing).toEqual(['beta'])
+    expect(fs.existsSync(live('beta'))).toBe(true)
+
+    const other = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-src2-'))
+    fs.mkdirSync(path.join(other, 'alpha'))
+    fs.writeFileSync(path.join(other, 'alpha', 'SKILL.md'), md('alpha'))
+    const s2 = app.skillSources.create({ name: '另一个来源', description: '', path: other, syncMode: 'manual', intervalMin: 10 })
+    expect(app.skillSources.scan(s2.id).skills[0]).toMatchObject({ name: 'alpha', status: 'conflict' })
+
+    const alphaId = app.customSkills.list().find(s => s.name === 'alpha')!.id
+    expect(() => app.customSkills.writeFile(alphaId, 'x.md', Buffer.from('x'))).toThrow(/只读/)
+    expect(app.customSkills.detach(alphaId).origin).toBe('authored')
+  })
+
+  it('自动同步:到期后导入有变化的技能并记入审计', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-auto-'))
+    fs.writeFileSync(path.join(dir, 'SKILL.md'), md('gamma'))
+    const s = app.skillSources.create({ name: '自动', description: '', path: dir, syncMode: 'auto', intervalMin: 1 })
+    await app.skillSources.runAutoSyncNow()
+    expect(app.customSkills.list().find(x => x.name === 'gamma')).toMatchObject({ publishedVersion: 1, sourceId: s.id })
+    expect(audits.some(a => a.startsWith('自动同步技能源 自动'))).toBe(true)
+  })
+
+  it('技能源路径校验:不能使用服务数据目录、不存在的 home 路径给出提示', () => {
+    expect(() => app.skillSources.create({ name: 'x', description: '', path: config.dataDir, syncMode: 'manual', intervalMin: 10 })).toThrow(/服务数据目录/)
+    expect(() => app.skillSources.create({ name: 'x', description: '', path: '/home/nobody/none', syncMode: 'manual', intervalMin: 10 })).toThrow(/ProtectHome/)
+  })
+})
+
+describe('自定义技能:zip 上传', () => {
+  it('上传 zip(带顶层目录)导入;不安全路径跳过并审计(C-03)', () => {
+    const zip = zipSync({
+      'pack/delta/SKILL.md': strToU8(md('delta')),
+      'pack/delta/templates/report.pptx': new Uint8Array([80, 75, 3, 4, 1, 2, 3]),
+      '../evil.md': strToU8('x'),
+      '__MACOSX/pack/._delta': strToU8('x'),
+    })
+    const { source, scan } = app.skillSources.upload('pack.zip', Buffer.from(zip))
+    expect(source.kind).toBe('upload')
+    expect(scan.skills.map(s => [s.name, s.path, s.fileCount])).toEqual([['delta', 'pack/delta', 2]])
+    expect(scan.skipped).toEqual(['../evil.md(路径不安全)'])
+    expect(audits.some(a => a.includes('上传技能包 pack.zip') && a.includes('跳过 1 个'))).toBe(true)
+    app.skillSources.import(source.id, ['delta'], scan.digest)
+    expect([...fs.readFileSync(live('delta', 'templates/report.pptx'))]).toEqual([80, 75, 3, 4, 1, 2, 3])
+  })
+
+  it('按实际解压字节数限制大小(防压缩炸弹)', () => {
+    const saved = LIMITS.unzipBytes
+    LIMITS.unzipBytes = 1048576
+    try {
+      const bomb = zipSync({ 'big/SKILL.md': new Uint8Array(3 * 1048576) }, { level: 9 })
+      expect(bomb.length).toBeLessThan(100_000)
+      expect(() => app.skillSources.upload('bomb.zip', Buffer.from(bomb))).toThrow(/解压后超过/)
+    } finally {
+      LIMITS.unzipBytes = saved
+    }
+    expect(() => app.skillSources.upload('bad.zip', Buffer.from('not a zip'))).toThrow()
+  })
+})
+
+describe('自定义技能:在线编写、草稿与发布、版本', () => {
+  let id: string
+
+  it('C-04 新建 → 上传模版并保存为草稿:不生效;发布后生效;恢复到上一版本', () => {
+    const d = app.customSkills.create({ name: 'report-kit', description: '生成月度报告的规范与模版,涉及月报时使用' })
+    id = d.id
+    expect(d.versions.map(v => [v.version, v.reason])).toEqual([[1, 'create']])
+    expect(d.publishedVersion).toBeNull()
+    expect(fs.existsSync(live('report-kit'))).toBe(false)
+
+    app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([1, 2, 3]))
+    expect(app.customSkills.detail(id).hasUnsavedChanges).toBe(true)
+    const v2 = app.customSkills.save(id, { baseVersion: 1, publish: false })
+    expect([v2.latestVersion, v2.publishedVersion, v2.hasUnsavedChanges]).toEqual([2, null, false])
+    expect(fs.existsSync(live('report-kit'))).toBe(false)
+    // 内容无变化不产生新版本;基于旧版本保存时冲突
+    expect(app.customSkills.save(id, { baseVersion: 2, publish: false }).latestVersion).toBe(2)
+    expect(() => app.customSkills.save(id, { baseVersion: 1, publish: false })).toThrow(/刷新/)
+
+    expect(app.customSkills.publish(id, 2).publishedVersion).toBe(2)
+    expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
+
+    app.customSkills.writeFile(id, 'templates/a.pptx', Buffer.from([9]))
+    app.customSkills.writeFile(id, 'SKILL.md', Buffer.from(md('report-kit', '新版描述:生成月度报告的规范与模版,涉及月报时使用')))
+    const v3 = app.customSkills.save(id, { baseVersion: 2, publish: true })
+    expect([v3.latestVersion, v3.publishedVersion, v3.description]).toEqual([3, 3, '新版描述:生成月度报告的规范与模版,涉及月报时使用'])
+    expect(v3.versions[0]!.changed).toEqual({ added: [], modified: ['SKILL.md', 'templates/a.pptx'], removed: [] })
+
+    const v4 = app.customSkills.restore(id, 2)
+    expect([v4.latestVersion, v4.publishedVersion, v4.versions[0]!.reason]).toEqual([4, 4, 'restore'])
+    expect([...fs.readFileSync(live('report-kit', 'templates/a.pptx'))]).toEqual([1, 2, 3])
+  })
+
+  it('SKILL.md 校验、路径安全、重命名与删除、放弃改动、导出 zip', () => {
+    app.customSkills.writeFile(id, 'SKILL.md', Buffer.from('---\nname: other\ndescription: x\n---\n'))
+    expect(() => app.customSkills.save(id, { baseVersion: 4, publish: false })).toThrow(/不一致/)
+    app.customSkills.discard(id)
+    expect(() => app.customSkills.writeFile(id, '../escape.md', Buffer.from('x'))).toThrow(/路径不正确/)
+    expect(() => normalizeRelPath('/abs')).toThrow()
+    expect(() => app.customSkills.deleteFile(id, 'SKILL.md')).toThrow(/不能删除/)
+    app.customSkills.writeFile(id, 'refs/a.md', Buffer.from('a'))
+    expect(app.customSkills.move(id, 'refs', 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'references/a.md', 'templates/a.pptx'])
+    expect(app.customSkills.deleteFile(id, 'references').files.map(f => f.path)).toEqual(['SKILL.md', 'templates/a.pptx'])
+
+    const z = app.customSkills.exportZip(id)
+    expect(z.name).toBe('report-kit-v4.zip')
+    expect(Object.keys(unzipSync(new Uint8Array(z.data))).sort()).toEqual(['report-kit/SKILL.md', 'report-kit/templates/a.pptx'])
+  })
+})
+
+describe('自定义技能:使用、沙箱与恢复', () => {
+  it('C-05 "全部技能"不包含自定义技能(可逐个设置包含);单独勾选后加载', () => {
+    const plugins = [
+      { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
+      app.customSkills.pluginRef()!,
+    ]
+    expect(resolveSkills('all', plugins).skills).toEqual(['agent-skills:r'])
+    expect(resolveSkills(['custom-skills:report-kit'], plugins).skills).toEqual(['custom-skills:report-kit'])
+
+    const id = app.customSkills.list().find(s => s.name === 'report-kit')!.id
+    app.customSkills.setFlags(id, { includeInAll: true })
+    expect(resolveSkills('all', [plugins[0]!, app.customSkills.pluginRef()!]).skills).toContain('custom-skills:report-kit')
+    expect(app.customSkills.excludedFromAll()).not.toContain('custom-skills:report-kit')
+
+    // Codex:all 时禁用需要单独勾选的自定义技能
+    const all = [
+      { name: 'custom-skills:alpha', path: path.join(customSkillsDir(), 'skills/alpha/SKILL.md') },
+      { name: 'custom-skills:Report Kit', path: path.join(customSkillsDir(), 'skills/report-kit/SKILL.md') },
+    ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
+    expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills', false, app.customSkills.excludedFromAll()).map(d => path.basename(path.dirname(d.path)))).toEqual(['alpha'])
+  })
+
+  it('停用后不加载,启用后恢复;指纹随之变化', () => {
+    const id = app.customSkills.list().find(s => s.name === 'alpha')!.id
+    const before = app.customSkills.fingerprint()
+    app.customSkills.setFlags(id, { enabled: false })
+    expect(fs.existsSync(live('alpha'))).toBe(false)
+    expect(app.customSkills.skillInfos().map(s => s.name)).not.toContain('alpha')
+    expect(app.customSkills.fingerprint()).not.toBe(before)
+    app.customSkills.setFlags(id, { enabled: true })
+    expect(fs.existsSync(live('alpha'))).toBe(true)
+  })
+
+  it('C-06 沙箱:生效目录只读可见,不能写;版本库不可读', () => {
+    const settings = app.settings.get()
+    const ws = path.join(config.dataDir, 'workspaces', 'ses_cs')
+    fs.mkdirSync(ws, { recursive: true })
+    const access = resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
+    expect(checkAccess(access, live('alpha'), 'read').allowed).toBe(true)
+    expect(checkAccess(access, live('alpha'), 'write').allowed).toBe(false)
+    expect(checkAccess(access, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
+    const broad = resolveAccess({ policy: { mode: 'broad', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings })
+    expect(checkAccess(broad, path.join(customSkillStoreDir(), 'ab', 'x'), 'read').allowed).toBe(false)
+    expect(checkAccess(broad, live('alpha'), 'read').allowed).toBe(true)
+  })
+
+  it('C-07 删除被两个 Profile 使用的技能:从两个 Profile 中移除', () => {
+    const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = app.profiles.getBuiltin()
+    const p1 = app.profiles.create({ ...input, name: 'p1', isDefault: false, skills: ['custom-skills:gamma', 'agent-skills:r'] })
+    const p2 = app.profiles.create({ ...input, name: 'p2', isDefault: false, skills: ['custom-skills:gamma'] })
+    const gamma = app.customSkills.list().find(s => s.name === 'gamma')!
+    expect(gamma.profileNames.sort()).toEqual(['p1', 'p2'])
+    app.customSkills.delete(gamma.id)
+    expect(app.profiles.get(p1.id).skills).toEqual(['agent-skills:r'])
+    expect(app.profiles.get(p2.id).skills).toEqual([])
+    expect(fs.existsSync(live('gamma'))).toBe(false)
+  })
+
+  it('C-08 重启时按数据库重建:被删除或改动的生效目录恢复,多余目录移除', () => {
+    fs.rmSync(path.join(customSkillsDir(), 'skills', 'alpha'), { recursive: true })
+    fs.writeFileSync(live('report-kit'), 'tampered')
+    fs.mkdirSync(path.join(customSkillsDir(), 'skills', 'stray'))
+    const logs: string[] = []
+    app.customSkills.reconcile(m => logs.push(m))
+    expect(fs.readFileSync(live('alpha'), 'utf8')).toContain('name: alpha')
+    expect(fs.existsSync(path.join(customSkillsDir(), 'skills', 'stray'))).toBe(false)
+    expect(logs.some(l => l.includes('alpha'))).toBe(true)
+    // 改动文件内容但标记未变:按标记判断不重新发布(运行时目录只能由服务写入)
+    expect(logs.some(l => l.includes('stray'))).toBe(true)
+  })
+})

+ 3 - 1
docs/README.md

@@ -21,7 +21,8 @@ docs/
 │  ├─ 08-platform-connectors.md  内部平台数据接入(MCP Gateway)
 │  ├─ 09-admin-ui.md          管理后台与 Playground(含视觉规范)
 │  ├─ 10-deployment.md        部署与运维
-│  └─ 11-roadmap.md           里程碑与待决事项
+│  ├─ 11-roadmap.md           里程碑与待决事项
+│  └─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -51,6 +52,7 @@ docs/
 | [0019](adr/0019-browser-tokens-and-sdk-distribution.md) | 浏览器令牌、CORS 与 SDK 包分发 | Accepted |
 | [0020](adr/0020-codex-fork-on-config-change.md) | Codex 会话配置变化时分叉 | Accepted |
 | [0021](adr/0021-third-party-mcp-via-gateway.md) | 第三方 MCP 服务经 MCP Gateway 转接 | Accepted |
+| [0022](adr/0022-managed-skill-dirs-readable-in-sandbox.md) | 服务管理的技能目录在沙箱中只读可见;Codex 的禁止规则绕开可读路径 | Accepted |
 
 ## 调研
 

+ 26 - 0
docs/adr/0022-managed-skill-dirs-readable-in-sandbox.md

@@ -0,0 +1,26 @@
+# ADR-0022 服务管理的技能目录在沙箱中只读可见;Codex 的禁止规则绕开可读路径
+
+- 状态:Accepted · 日期:2026-10-25 · 补充 ADR-0010、ADR-0017
+
+## 背景
+实施自定义技能时发现两个问题,也影响已有的第三方技能:
+
+1. 内置全局禁止项包含 `{dataDir}/data`(数据库、版本库、MCP 数据都在这里),而第三方技能与自定义技能的生效目录 `data/community-skills`、`data/custom-skills` 也在其中。
+   - Claude 的 Read 等工具经过 `checkAccess`,全局禁止项优先于可读路径,所以读不到技能的参考文档。
+   - Codex 的沙箱同样禁止。
+   - SKILL.md 本身由引擎进程读取,所以技能看起来"能触发",但参考文档和脚本用不了。
+2. Codex(0.164)的文件权限中,父目录为 deny 时子路径的 read 无效,write 有效。已实测:父 deny + 子 read,读取被拒;父 deny + 子 write,写入正常。
+   - 因此宽松模式下 `softDeny` 中的服务目录(`/srv/agent-server`)会让 Codex 读不到其中的本地技能目录、Python 运行环境与官方技能目录。
+   - 当前工作区可写,不受影响。
+
+## 决策
+- `checkAccess` 对服务管理的技能目录(`managedSkillRoots()`:第三方、自定义技能的生效目录)只放行读取,并且先于全局禁止项判断。data 下的其他内容仍然禁止,写入仍然禁止。
+- Claude 的 Bash 沙箱:`denyRead` 中的 data 目录与 `allowRead` 中的技能目录同时存在时,更具体的 allowRead 生效(已实测)。无需改动。
+- Codex 的权限表:要禁止的目录(全局禁止项、宽松模式的 softDeny)如果包含必须可读的路径(表中所有 read 项),不再禁止该目录本身,而是逐层展开,逐个禁止通往可读路径以外的其他条目(`denyAround`)。
+  - 已在生产 Codex 上验证:可读路径能读、不能写,同级条目被拒。
+  - 展开在每次对话时计算。之后新出现的条目要到下一次对话才被禁止;这些目录只由服务写入。
+
+## 后果
+- 第三方技能与自定义技能的参考文档、脚本、模版在两个引擎中都可用
+- 宽松模式下 Codex 可以使用本地技能目录与 Python 运行环境
+- 自定义技能的版本库(`data/custom-skill-store`)、数据库等仍不可见

+ 210 - 0
docs/design/12-custom-skills.md

@@ -0,0 +1,210 @@
+# 12 自定义技能
+
+> 状态:**已实施(2026-10-25)**,一、二、三期全部完成,实施中的调整见 §11。由 FranklinWH ToolBox 新 AI 助手的需要引出(业务平台需要自己的、非公开的技能),内容是通用功能,不含业务平台专有逻辑。基于 `0af6089` 的现有设计(ADR-0013、0016、0017,`providers/claude/skills.ts`、`providers/codex/skills.ts`、`services/community-skills.ts`)。
+
+## 1. 背景
+
+### 1.1 现有的技能来源
+
+| 来源 | 存放位置 | 如何进入 | 技能 ID | 引擎 | Profile 中 |
+| --- | --- | --- | --- | --- | --- |
+| 本地技能目录(local) | 设置项 `paths.skillsDir`,生产为 `/srv/agent-server/skills` | AgentServer 仓库 `skills/` 经 `deploy.sh` 的 `rsync --delete` 发布 | `agent-skills:<name>` | 两个(Claude 按路径加载插件,Codex 经 `skills/extraRoots/set`) | 包含在“全部技能”中 |
+| Claude 插件(plugin) | Claude Code 自己的插件目录 | 后台“技能与插件”调用 `claude plugin` 安装官方市场插件 | `document-skills:xlsx` 等 | 仅 Claude(ADR-0013) | 包含在“全部” |
+| Codex 官方(official) | `CODEX_HOME/skills/<name>` | 从 openai/skills 固定提交复制 | `openai-skills:<name>` | 仅 Codex(ADR-0016) | 包含在“全部” |
+| 第三方(community) | `<dataDir>/data/community-skills` | skills.sh 搜索 → 预览 → 按 GitHub 固定提交安装,只收开源许可 | `community-skills:<name>` | 两个 | 需单独勾选(optIn) |
+| 引擎自带(system) | 引擎内部 | — | — | — | — |
+
+### 1.2 缺口
+
+使用 AgentServer 的业务平台(例如 ToolBox)需要**自己的、非公开的**技能:业务背景、工具使用流程、内部模版。现有来源都不合适:
+
+- 本地技能目录属于 AgentServer 仓库,部署时被 `rsync --delete` 覆盖;把业务技能放进去,会让通用平台夹带业务内容;把 `paths.skillsDir` 改到别处,又会丢掉自带的 `report-conventions`。
+- 第三方技能只能来自 skills.sh 收录的公开 GitHub 仓库,并要求开源许可,内部内容不适合公开。
+- 插件与官方技能只面向厂商仓库。
+- 后台没有上传技能、登记技能目录或在线编写技能的入口。
+
+## 2. 目标
+
+新增一类技能来源 **自定义技能(custom)**,满足:
+
+1. **从外部导入**:登记服务器上的一个目录,或上传 zip,把其中的技能导入 AgentServer;目录内容更新后可以再次同步。适用于技能由业务平台的仓库维护(如某个业务平台仓库中的技能目录)。
+2. **在后台编写**:在后台直接新建、编辑技能(SKILL.md、参考文档、脚本、模版文件),保存即生效,有版本历史。适用于运营人员调整提示和规范。
+3. **与现有技能一致的使用方式**:两个引擎都能用,在 Profile 中勾选,沙箱中只读,审计可查。
+
+非目标:不做技能市场与分享;不做多管理员协作编辑与权限;不改变现有四种来源的行为。
+
+## 3. 概念与存储
+
+### 3.1 统一存储,两种来源
+
+所有自定义技能都存放在 AgentServer 数据目录内,采用 Claude 插件格式(与第三方技能相同):
+
+```
+<dataDir>/data/custom-skills/
+  .claude-plugin/plugin.json        { "name": "custom-skills" }
+  skills/<name>/SKILL.md            生效版本(运行时只读这里)
+  skills/<name>/references/…
+  skills/<name>/templates/…
+<dataDir>/data/custom-skill-store/  按内容寻址的文件库(sha256),保存各版本的文件
+```
+
+- 技能 ID:`custom-skills:<name>`,两个引擎一致。
+- 每个技能有一个**来源类型**:
+  - `authored`:后台编写,可在线编辑;
+  - `imported`:从目录或 zip 导入,记录来源;默认只读(避免与来源不一致),可以“转为可编辑”(之后与来源脱钩,不再同步)。
+- 运行时只读 `custom-skills/skills/` 下的生效版本;编辑、导入都先写入版本库,再“发布”为生效版本。这样无论来源如何,加载路径和 ID 都一致。
+
+不采用“直接引用外部目录、实时加载”:外部目录没有插件清单时 Claude 会用目录名作前缀,ID 不稳定;目录内容随时变化,无法审阅和回滚;还要处理符号链接与越权路径。导入快照与第三方技能的做法一致(只复制普通文件、固定版本)。
+
+### 3.2 技能源(导入来源)
+
+| 字段 | 说明 |
+| --- | --- |
+| `id`、`name`、`description` | 后台显示 |
+| `kind` | `directory`(服务器路径)/ `upload`(zip 上传,一次性) |
+| `path` | `directory` 时的绝对路径;可以是插件格式目录(`skills/*/SKILL.md`)、技能集合目录(`*/SKILL.md`)或单个技能目录(含 `SKILL.md`) |
+| `syncMode` | `manual`(默认)/ `auto`(定时检查,有变化时自动导入并发布) |
+| `intervalMin` | 自动同步间隔,默认 10 分钟 |
+| `lastSyncAt`、`lastSyncDigest`、`status`、`lastError` | 同步状态 |
+
+路径校验与资料库一致(`KnowledgeService` 的检测逻辑可复用):存在、服务进程能看到(受 `ProtectHome` 影响,home 下的路径通常不可见,需要放在 home 以外或绑定挂载)、不命中全局禁止项、不在服务数据目录与会话工作区内。
+
+## 4. 功能需求
+
+### FR-1 导入与同步
+
+- 登记目录后点“扫描”:列出发现的技能(名称、描述、文件数、大小、与当前版本的差异:新增 / 修改 / 删除的文件)。
+- 勾选要导入的技能,确认后导入为新版本并发布。
+- “同步”:重新扫描,只显示有变化的技能和文件差异,确认后导入。`auto` 模式下跳过确认,直接导入并发布,审计记录变化摘要。
+- 上传 zip:解压到临时目录后走同样的扫描 → 确认流程。
+- 来源目录中删除了某个技能:同步时提示“来源中已不存在”,由管理员选择保留或删除,自动同步不自动删除。
+- 导入规则沿用第三方技能:只复制普通文件,跳过符号链接;名称取 SKILL.md frontmatter 的 `name`(缺省用目录名),必须匹配 `^[a-z0-9][a-z0-9-]{0,63}$`;同名技能已存在且来源不同时拒绝导入。
+
+### FR-2 在线编写
+
+- 新建技能:填写名称、描述,生成 SKILL.md 模版(frontmatter + 章节骨架)。
+- 文件树编辑:新建 / 重命名 / 删除文件和目录;文本文件(md、txt、json、yaml、csv、py、js、sh 等)用 Monaco 编辑(后台已在 Profile 提示词中使用 Monaco);二进制文件(pptx、docx、xlsx、png 等模版)只能上传 / 替换 / 下载。
+- SKILL.md 校验:frontmatter 必须有 `name`(与技能名一致)、`description`(非空,建议 ≤ 1024 字符,过短给出提醒:描述决定技能是否被触发);正文 ≤ 200KB(与第三方技能的 `MAX_SKILL_MD` 一致)。
+- 保存:生成新版本(内容无变化不产生版本,与 Profile 版本历史一致),可选“保存并发布”或“保存为草稿”。草稿不影响运行时。
+- 预览:渲染 SKILL.md;显示该技能在引擎中的完整 ID 与被哪些 Profile 使用。
+- 导出:下载当前版本为 zip(便于回写到业务平台仓库)。
+
+### FR-3 版本
+
+- 每个技能保留全部版本:版本号、时间、来源(编辑 / 导入 / 同步 / 恢复)、改动的文件列表。
+- 查看任意版本、与当前版本对比(文本文件逐行差异)。
+- 恢复:把旧版本另存为新版本并发布(与 Profile 的“恢复保存为新版本”一致)。
+
+### FR-4 使用
+
+- Profile 的技能选择中出现 `custom-skills:<name>`,归为“自定义”分组。
+- 默认需要单独勾选(与第三方技能相同的 optIn):自定义技能通常面向特定业务,不应出现在选择“全部技能”的 Profile 中。每个技能可以设置“包含在‘全部技能’中”,默认关。
+- 技能停用:保留内容,运行时不加载,Profile 中显示为“已停用”。
+- 技能删除:二次确认;从引用它的 Profile 中移除(与删除 MCP 服务的处理一致),版本库文件随后清理。
+- Playground 中可以临时勾选自定义技能,便于编辑后立即测试。
+
+### FR-5 引擎接入
+
+- Claude:与第三方技能相同,`custom-skills` 目录作为本地插件按路径加载(`PluginRef`,`optIn: true`),由 `resolveSkills` 按 Profile 选择。每次对话都会启动新的 CLI 进程,发布后下一轮即生效。
+- Codex:把 `custom-skills/skills` 加入 `skills/extraRoots/set`(目前只在 app-server 启动时设置一次,`providers/codex/adapter.ts`)。需要在自定义技能发布后重新设置 extraRoots;已加载的会话按 ADR-0020 的方式处理:把自定义技能的版本指纹放入会话配置,变化时分叉,使新版本生效。`skillsToDisable` 按 Profile 选择禁用未勾选的自定义技能。
+- 沙箱:`custom-skills` 目录在两个引擎中只读可见(与 `communitySkillsDir()` 同样加入 `resolveAccess` 的基线可读路径);`custom-skill-store` 不可见。
+
+### FR-6 审计
+
+记录:技能源的增删改、扫描与同步(含变化摘要)、技能创建 / 保存 / 发布 / 恢复 / 停用 / 删除(含版本号与改动文件列表)、zip 上传(文件名、大小、sha256)。
+
+## 5. 非功能与安全
+
+| 项 | 要求 |
+| --- | --- |
+| 限额 | 单个技能 ≤ 500 个文件、≤ 20MB(与第三方技能一致);单个文件 ≤ 10MB;zip ≤ 50MB;自定义技能总数 ≤ 200 |
+| 文件安全 | 路径规范化,拒绝绝对路径、`..`、隐藏的控制字符;只写入技能自己的目录;zip 解压防路径穿越与压缩炸弹(按解压后大小计) |
+| 脚本 | 技能中的脚本只在 Agent 沙箱内由 Agent 执行,AgentServer 不在导入或保存时执行任何文件 |
+| 原子发布 | 先写入临时目录,再整体替换 `skills/<name>`,运行中的对话不会读到一半新、一半旧的文件 |
+| 并发 | 同一技能同时只允许一个保存 / 导入操作;编辑时以版本号做乐观锁,冲突时提示刷新 |
+| 可恢复 | 服务重启后由数据库与版本库重建生效目录(启动时校验 `skills/` 与当前版本一致,不一致则重新发布) |
+
+## 6. 数据模型
+
+| 表 | 字段 |
+| --- | --- |
+| `custom_skills` | `id`、`name`(唯一)、`description`、`origin`(authored / imported)、`sourceId`(可空)、`enabled`、`includeInAll`、`publishedVersion`、`draftVersion`(可空)、`createdAt`、`updatedAt` |
+| `custom_skill_versions` | `skillId`、`version`、`createdAt`、`reason`(edit / import / sync / restore)、`files`(JSON:路径 → sha256、大小)、`changed`(相对上一版本的增删改文件) |
+| `skill_sources` | 见 3.2 |
+
+文件内容存放在 `custom-skill-store/<sha256 前两位>/<sha256>`,各版本共享相同文件;没有版本引用的文件在删除技能或定期任务中清理。
+
+## 7. 接口(后台 `/admin/v1`)
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET / POST | `/custom-skills` | 列表 / 新建(authored) |
+| GET / PATCH / DELETE | `/custom-skills/:id` | 详情(含文件树、版本列表、被哪些 Profile 使用)/ 改描述、启停、`includeInAll` / 删除 |
+| GET / PUT / DELETE | `/custom-skills/:id/files/*path` | 读 / 写(文本或 multipart 二进制)/ 删除草稿中的文件 |
+| POST | `/custom-skills/:id/save` | `{ baseVersion, publish }` 保存为新版本,可同时发布 |
+| GET | `/custom-skills/:id/versions/:v`、`/custom-skills/:id/diff?from=&to=` | 版本详情 / 对比 |
+| POST | `/custom-skills/:id/versions/:v/restore` | 恢复为新版本并发布 |
+| GET | `/custom-skills/:id/export` | 下载 zip |
+| POST | `/custom-skills/:id/detach` | 导入的技能转为可编辑 |
+| GET / POST / PATCH / DELETE | `/skill-sources[/:id]` | 技能源管理 |
+| POST | `/skill-sources/:id/scan` | 扫描,返回技能与差异 |
+| POST | `/skill-sources/:id/import` | `{ skills: [name], scanDigest }` 按扫描结果导入(摘要不一致时拒绝,避免导入未审阅的内容) |
+| POST | `/skill-sources/upload` | 上传 zip,返回扫描结果 |
+
+`GET /admin/v1/skills` 的结果中加入 `source: 'custom'` 的技能(`SkillInfo.source` 增加 `custom`)。
+
+## 8. 后台界面
+
+在“技能与插件”页增加“自定义技能”标签(现有标签:Claude、Codex、Codex 插件、第三方):
+
+- 左侧:技能列表(名称、来源图标“编写 / 导入”、启停、是否有未发布草稿),顶部“新建技能”“导入”按钮;下方“技能源”列表(路径、同步模式、最近同步状态、“同步”按钮)。
+- 右侧:选中技能后显示文件树 + 编辑器;顶部为名称、描述、ID、使用它的 Profile、“保存草稿”“发布”“版本历史”“导出”;导入的技能显示来源与“转为可编辑”。
+- 导入 / 同步对话框:技能列表 + 文件差异(文本逐行)+ 确认。
+
+## 9. 分期
+
+| 期 | 内容 |
+| --- | --- |
+| 一期 | 存储与加载(两个引擎、Profile 勾选、沙箱只读);目录技能源的扫描 / 手动同步;zip 上传导入;审计 |
+| 二期 | 在线编写(文件树、Monaco、二进制上传)、草稿与发布、版本历史与恢复、导出 |
+| 三期 | 自动同步、版本对比视图、`includeInAll` 等细节 |
+
+一期完成后,业务平台即可把自己仓库中的技能目录作为技能源导入(路径需对服务可见,见 3.2),或打包为 zip 上传。
+
+## 10. 验收用例
+
+| 编号 | 用例 |
+| --- | --- |
+| C-01 | 登记一个目录技能源,扫描出 2 个技能,导入后在 Profile 中可勾选;Claude 与 Codex 的对话都能触发该技能,时间线中技能 ID 为 `custom-skills:<name>` |
+| C-02 | 修改来源目录中的一个参考文件,同步时只显示该文件的差异;确认后下一轮对话读到新内容 |
+| C-03 | 来源目录中放一个指向 `/etc/passwd` 的符号链接和一个 `../` 路径的 zip 条目:都被跳过或拒绝,并写入审计 |
+| C-04 | 后台新建技能、上传一个 pptx 模版、保存为草稿:运行时不生效;发布后生效;恢复到上一版本后又回到旧内容 |
+| C-05 | 选择“全部技能”的 Profile 不包含自定义技能;单独勾选后才加载 |
+| C-06 | Agent 尝试写入技能目录被拒绝;读取 `custom-skill-store` 被拒绝 |
+| C-07 | 删除一个被两个 Profile 使用的技能:二次确认后两个 Profile 中都已移除 |
+| C-08 | 服务重启后自定义技能仍可用;手动删除 `custom-skills/skills/<name>` 后重启,自动按当前版本恢复 |
+
+## 11. 实施记录(2026-10-25)
+
+代码:`apps/server/src/custom-skills/`(`store.ts` 文件库与文件工具、`service.ts` 技能与版本、`sources.ts` 技能源与 zip),后台"技能与插件 › 自定义技能"(`CustomSkillsPanel.vue`),测试 `test/custom-skills.test.ts` 覆盖 C-01 ~ C-08。
+
+与草案不同的地方:
+
+| 草案 | 实施 | 原因 |
+| --- | --- | --- |
+| 文本文件用 Monaco 编辑("后台已在 Profile 提示词中使用") | 等宽文本框 | 后台并没有使用 Monaco(Profile 提示词是普通文本框),引入会增加数 MB 体积;以后需要再换 CodeMirror 等 |
+| 发布后重新设置 Codex 的 `extraRoots` | 服务启动时就创建 `custom-skills/skills`,Codex 启动时把它加入 `extraRoots`;之后 Codex 每次列技能都会重新扫描该目录 | 已验证新增的技能无需重新设置即可出现;已加载会话按 ADR-0020 处理:自定义技能的发布状态指纹放进网关地址,变化时会话分叉 |
+| 草稿 `draftVersion` | 编辑中的改动保存在 `custom_skills.working_files`;"保存草稿"生成未发布的新版本,"保存并发布"同时发布 | 与 Profile 的"内容无变化不产生版本"一致,也便于"放弃改动"与"查看改动" |
+| 停用:运行时不加载 | 停用时整体移除生效目录,启用时按生效版本重新写入 | 两个引擎都只看生效目录,不需要各自过滤 |
+| 删除技能源 | 从它导入的技能保留,转为可编辑 | 草案未规定;避免误删 |
+
+实施中发现并修复的问题(同样影响之前的第三方技能,见 ADR-0022):
+
+- 内置全局禁止项包含 `{dataDir}/data`,技能生效目录位于其中,Agent 读不到技能的参考文档、运行不了脚本(Claude 的 Read 工具检查与 Codex 的沙箱都会拒绝)。现在只读放开服务管理的两个技能目录。
+- Codex 中父目录为 deny 时,子路径的 read 无效(write 有效)。禁止一个包含可读路径的目录时,改为逐个禁止其他内容。这也修复了宽松模式下 Codex 读不到本地技能目录、Python 运行环境与官方技能的问题。
+
+验收:
+
+- Claude 实测:自定义技能被触发(时间线中为 `custom-skills:e2e-lookup`),Bash 读取技能的参考文件成功,读取 `data/agent.db` 被拒绝。
+- Codex 实测:在生产 Codex 上验证了新的权限布局(技能文件可读、不可写,同级的数据库文件不可读)。
+- 后台实测:编写、上传 zip 导入、版本对比、Profile 中勾选。

+ 114 - 2
packages/shared/src/api.ts

@@ -201,8 +201,10 @@ export interface SkillInfo {
   name: string
   description: string
   plugin: string
-  /** local 本地技能目录;plugin Claude 插件;official 从官方仓库安装(Codex);system 引擎自带;community 第三方技能(两个引擎共用,需在 Profile 中单独启用) */
-  source: 'local' | 'plugin' | 'official' | 'system' | 'community'
+  /** local 本地技能目录;plugin Claude 插件;official 从官方仓库安装(Codex);system 引擎自带;community 第三方技能;custom 自定义技能(后两者两个引擎共用) */
+  source: 'local' | 'plugin' | 'official' | 'system' | 'community' | 'custom'
+  /** 需要在 Profile 中单独勾选(选择"全部技能"时不包含) */
+  optIn?: boolean
   license: LicenseKind
 }
 
@@ -559,3 +561,113 @@ export interface McpTestResultDto {
   stderrTail: string | null
   ms: number
 }
+
+// ---- 自定义技能(导入或在后台编写,两个引擎共用,docs/design/12-custom-skills.md)----
+
+/** 自定义技能的插件名,技能 ID 形如 custom-skills:<name> */
+export const CUSTOM_SKILL_PLUGIN = 'custom-skills'
+
+export interface CustomSkillFileDto {
+  path: string
+  size: number
+  sha: string
+  /** 可以在后台以文本方式编辑 */
+  text: boolean
+}
+
+export interface FileChangesDto {
+  added: string[]
+  modified: string[]
+  removed: string[]
+}
+
+export interface CustomSkillVersionDto {
+  version: number
+  createdAt: string
+  /** create 新建;edit 编辑保存;import 首次导入;sync 同步;restore 恢复 */
+  reason: 'create' | 'edit' | 'import' | 'sync' | 'restore'
+  changed: FileChangesDto
+  fileCount: number
+  size: number
+}
+
+export interface CustomSkillDto {
+  id: string
+  /** 技能名,也是目录名;技能 ID 为 custom-skills:<name> */
+  name: string
+  description: string
+  origin: 'authored' | 'imported'
+  sourceId: string | null
+  sourceName: string | null
+  enabled: boolean
+  /** 包含在 Profile 的"全部技能"中(默认否,需要单独勾选) */
+  includeInAll: boolean
+  /** 运行时生效的版本;null 表示还没有发布 */
+  publishedVersion: number | null
+  latestVersion: number
+  /** 编辑中、尚未保存为版本的改动 */
+  hasUnsavedChanges: boolean
+  profileNames: string[]
+  createdAt: string
+  updatedAt: string
+}
+
+export interface CustomSkillDetailDto extends CustomSkillDto {
+  /** 编辑中的文件(未保存的改动也在这里) */
+  files: CustomSkillFileDto[]
+  versions: CustomSkillVersionDto[]
+}
+
+export interface SkillSourceInput {
+  name: string
+  description: string
+  /** 服务器上的绝对路径 */
+  path: string
+  syncMode: 'manual' | 'auto'
+  intervalMin: number
+}
+
+export interface SkillSourceDto extends SkillSourceInput {
+  id: string
+  /** directory 服务器目录;upload 上传的 zip(一次性) */
+  kind: 'directory' | 'upload'
+  lastSyncAt: string | null
+  status: 'never' | 'ok' | 'error'
+  lastError: string | null
+  /** 从这个来源导入的技能 */
+  skillNames: string[]
+  createdAt: string
+}
+
+export interface SkillScanItemDto {
+  name: string
+  description: string
+  /** 在来源中的相对路径 */
+  path: string
+  fileCount: number
+  size: number
+  /** new 新技能;changed 有变化;unchanged 无变化;conflict 同名技能已存在且来源不同;invalid 不能导入 */
+  status: 'new' | 'changed' | 'unchanged' | 'conflict' | 'invalid'
+  reason: string | null
+  changes: FileChangesDto
+  warnings: string[]
+}
+
+export interface SkillScanDto {
+  sourceId: string
+  /** 导入时回传,扫描结果变化时拒绝导入(避免导入未审阅的内容) */
+  digest: string
+  skills: SkillScanItemDto[]
+  /** 从这个来源导入过、但来源中已不存在的技能 */
+  missing: string[]
+  /** 跳过的文件(符号链接、特殊文件等) */
+  skipped: string[]
+}
+
+export interface FileDiffDto {
+  path: string
+  status: 'added' | 'removed' | 'modified'
+  binary: boolean
+  /** 文本文件的统一差异格式 */
+  patch: string | null
+}

+ 15 - 0
pnpm-lock.yaml

@@ -80,12 +80,18 @@ importers:
       better-sqlite3:
         specifier: ^13.0.3
         version: 13.0.3
+      diff:
+        specifier: ^8.0.2
+        version: 8.0.4
       drizzle-orm:
         specifier: ^0.45.4
         version: 0.45.4(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)
       fastify:
         specifier: ^5.12.7
         version: 5.12.7
+      fflate:
+        specifier: ^0.8.2
+        version: 0.8.3
       mime-types:
         specifier: ^3.0.2
         version: 3.0.2
@@ -2629,6 +2635,11 @@ packages:
       - srvx
     dev: true
 
+  /diff@8.0.4:
+    resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+    engines: {node: '>=0.3.1'}
+    dev: false
+
   /drizzle-kit@0.31.11:
     resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==}
     hasBin: true
@@ -3062,6 +3073,10 @@ packages:
     dependencies:
       picomatch: 4.0.7
 
+  /fflate@0.8.3:
+    resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+    dev: false
+
   /finalhandler@2.1.1:
     resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
     engines: {node: '>= 18.0.0'}