Просмотр исходного кода

feat: 第三方技能(skills.sh),两个引擎共用

- 后台"技能与插件"新增"第三方技能"页:搜索 skills.sh → 预览 SKILL.md 与文件列表(必须勾选已阅读)→ 按预览时的提交号从 GitHub 安装
- 只允许开源许可(技能目录 LICENSE → SKILL.md license 字段 → 仓库根目录 LICENSE);anthropics/skills、openai/skills 不走这里
- 安装到 data/community-skills(Claude 插件格式):Claude 按路径加载为插件,Codex 通过 extraRoots 加入;技能 ID 统一为 community-skills:<目录名>
- 默认不启用:Profile 选择"全部技能"时不包含第三方技能,需要单独勾选
- 只复制普通文件(跳过符号链接),限制文件数与大小;owner/repo 严格校验
- SKILL.md 头部解析抽到 lib/skill-files.ts,支持多行写法
- 修复技能页列表分隔线左侧多出边框
- ADR-0017,路线图更新

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 неделя назад
Родитель
Сommit
2025d5f623

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

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexSkillCatalogDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
   ProviderStatusDto, ResolvedAccessDto, SkillInfo,
 } from '@agent-server/shared'
 
@@ -40,6 +40,13 @@ export const admin = {
     install: (name: string) => req<CodexSkillCatalogDto>('POST', `/codex-skills/${encodeURIComponent(name)}`),
     uninstall: (name: string) => req<CodexSkillCatalogDto>('DELETE', `/codex-skills/${encodeURIComponent(name)}`),
   },
+  communitySkills: {
+    list: () => req<CommunitySkillDto[]>('GET', '/community-skills'),
+    search: (q: string) => req<CommunitySearchResultDto[]>('GET', `/community-skills/search?q=${encodeURIComponent(q)}`),
+    preview: (source: string, skillId: string) => req<CommunitySkillPreviewDto>('POST', '/community-skills/preview', { source, skillId }),
+    install: (source: string, skillId: string, sha: string) => req<CommunitySkillDto[]>('POST', '/community-skills/install', { source, skillId, sha }),
+    uninstall: (name: string) => req<CommunitySkillDto[]>('DELETE', `/community-skills/${encodeURIComponent(name)}`),
+  },
   plugins: (refresh = false) => req<PluginStateDto>('GET', `/plugins${refresh ? '?refresh=1' : ''}`),
   pluginAction: (a: PluginAction) => req<{ message: string; state: PluginStateDto }>('POST', '/plugins/actions', a),
   profiles: () => req<ProfileDto[]>('GET', '/profiles'),

+ 187 - 10
apps/admin/src/views/PluginsView.vue

@@ -1,14 +1,16 @@
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue'
-import type { AvailablePluginDto, CodexSkillCatalogDto, PluginAction, PluginStateDto } from '@agent-server/shared'
+import type {
+  AvailablePluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
+} from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
 const OFFICIAL_SOURCE = 'anthropics/skills'
 
-// 每个引擎接入各自厂商的官方技能仓库
-const tab = ref<'claude' | 'codex'>('claude')
+// 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
+const tab = ref<'claude' | 'codex' | 'community'>('claude')
 const codex = ref<CodexSkillCatalogDto | null>(null)
 const codexBusy = ref<string | null>(null)
 const codexSearch = ref('')
@@ -42,6 +44,73 @@ async function codexAct(key: string, fn: () => Promise<CodexSkillCatalogDto>, do
   }
 }
 
+// 第三方技能:skills.sh 搜索 → 预览 SKILL.md → 按提交安装
+const community = ref<CommunitySkillDto[]>([])
+const communityQuery = ref('')
+const communityResults = ref<CommunitySearchResultDto[] | null>(null)
+const communityBusy = ref<string | null>(null)
+const preview = ref<CommunitySkillPreviewDto | null>(null)
+const previewRead = ref(false)
+const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
+
+async function loadCommunity() {
+  community.value = await admin.communitySkills.list().catch(err => (toast(errorMessage(err), 'error'), []))
+}
+
+async function searchCommunity() {
+  const q = communityQuery.value.trim()
+  if (q.length < 2) return
+  communityBusy.value = 'search'
+  try {
+    communityResults.value = await admin.communitySkills.search(q)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    communityBusy.value = null
+  }
+}
+
+async function openPreview(source: string, skillId: string) {
+  communityBusy.value = `${source}/${skillId}`
+  try {
+    preview.value = await admin.communitySkills.preview(source, skillId)
+    previewRead.value = false
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    communityBusy.value = null
+  }
+}
+
+async function installPreview() {
+  const p = preview.value
+  if (!p) return
+  communityBusy.value = 'install'
+  try {
+    community.value = await admin.communitySkills.install(p.source, p.skillId, p.sha)
+    toast(`${p.name} 已${p.installed ? '更新' : '安装'},需要在 Profile 中勾选后才会启用`, 'success')
+    preview.value = null
+    if (communityResults.value) void searchCommunity()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    communityBusy.value = null
+  }
+}
+
+async function uninstallCommunity(name: string) {
+  if (!window.confirm(`卸载第三方技能 ${name}?已勾选它的 Profile 将不再加载这个技能。`)) return
+  communityBusy.value = name
+  try {
+    community.value = await admin.communitySkills.uninstall(name)
+    toast(`${name} 已卸载`, 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    communityBusy.value = null
+  }
+}
+
 const state = ref<PluginStateDto | null>(null)
 const loading = ref(false)
 const busy = ref<string | null>(null)
@@ -99,24 +168,91 @@ function addMarketplace(source: string) {
 onMounted(() => {
   void load()
   void loadCodex()
+  void loadCommunity()
 })
 </script>
 
 <template>
   <div class="h-full flex flex-col">
-    <PageHeader title="技能与插件" subtitle="每个引擎接入各自厂商的官方技能仓库,只提供给对应的引擎">
-      <button type="button" class="btn-secondary" :disabled="loading" @click="load(true); loadCodex()"><span class="codicon codicon-refresh" />刷新</button>
+    <PageHeader title="技能与插件" subtitle="官方技能只提供给对应厂商的引擎;第三方技能两个引擎共用">
+      <button type="button" class="btn-secondary" :disabled="loading" @click="load(true); loadCodex(); loadCommunity()"><span class="codicon codicon-refresh" />刷新</button>
     </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 官方技能']] as const)" :key="t[0]" type="button"
+        v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['community', '第三方技能 · 两个引擎']] 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">
+      <!-- 第三方技能:skills.sh 目录 → GitHub -->
+      <div v-if="tab === 'community'" class="max-w-240 p-6 flex flex-col gap-6">
+        <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
+          <span class="codicon codicon-shield text-warning text-[18px] mt-0.5" />
+          <div class="flex flex-col gap-1.5 text-[12.5px]">
+            <div class="font-600 text-[13px]">来源:<a href="https://skills.sh" target="_blank" rel="noopener noreferrer" class="text-link">skills.sh</a> 目录中的 GitHub 仓库(社区作者,未经审核)</div>
+            <div>第三方技能会成为 Agent 的指令,可能附带脚本。安装前<b>必须先查看 SKILL.md 全文与文件列表</b>;技能按预览时的提交版本安装,之后仓库的改动不会自动生效。<b>只允许安装开源许可</b>的技能,Anthropic / OpenAI 官方仓库请在各自的页面安装。</div>
+            <div>安装后两个引擎都能使用,但<b>默认不启用</b>:需要在 Profile 中取消"启用全部可用技能"并单独勾选。脚本与其他命令一样在沙箱中执行。</div>
+          </div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">已安装的第三方技能</h2>
+          <div class="card divide-y divide-x-0 divide-solid divide-border">
+            <div v-for="s in community" :key="s.name" class="flex items-start gap-3 px-4 py-2.5">
+              <div class="flex-1 min-w-0">
+                <div class="flex items-center gap-2 flex-wrap">
+                  <span class="font-mono text-[12.5px]">{{ s.id }}</span>
+                  <span class="badge bg-code text-success">{{ s.license }}</span>
+                  <a :href="`https://github.com/${s.source}/tree/${s.sha}/${s.path}`" target="_blank" rel="noopener noreferrer" class="hint font-mono text-link">{{ s.source }}@{{ s.sha.slice(0, 8) }}</a>
+                </div>
+                <div class="hint line-clamp-2">{{ s.description }}</div>
+                <div class="hint">安装于 {{ formatTime(s.installedAt) }}</div>
+              </div>
+              <button type="button" class="btn-ghost flex-none" :disabled="!!communityBusy" title="获取仓库最新版本并预览" @click="openPreview(s.source, s.skillId)">
+                <span class="codicon" :class="communityBusy === `${s.source}/${s.skillId}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />检查更新
+              </button>
+              <button type="button" class="icon-btn flex-none" title="卸载" :disabled="!!communityBusy" @click="uninstallCommunity(s.name)"><span class="codicon codicon-trash" /></button>
+            </div>
+            <div v-if="!community.length" class="px-4 py-3 hint">还没有安装第三方技能</div>
+          </div>
+        </section>
+
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">搜索 skills.sh</h2>
+          <form class="flex gap-2" @submit.prevent="searchCommunity">
+            <div class="relative flex-1 max-w-100">
+              <span class="codicon codicon-search absolute left-2 top-1/2 -translate-y-1/2 text-fg-muted text-[13px]" />
+              <input v-model="communityQuery" class="field pl-7" placeholder="例如 excel、research、chart(至少 2 个字符)" aria-label="搜索第三方技能">
+            </div>
+            <button type="submit" class="btn-secondary" :disabled="communityQuery.trim().length < 2 || !!communityBusy">
+              <span class="codicon" :class="communityBusy === 'search' ? 'codicon-loading codicon-modifier-spin' : 'codicon-search'" />搜索
+            </button>
+          </form>
+          <div v-if="communityResults" class="card divide-y divide-x-0 divide-solid divide-border">
+            <div v-for="r in communityResults" :key="`${r.source}/${r.skillId}`" class="flex items-center gap-3 px-4 py-2.5">
+              <div class="flex-1 min-w-0">
+                <div class="flex items-center gap-2">
+                  <span class="font-500">{{ r.name }}</span>
+                  <span v-if="r.installed" class="badge bg-code text-accent">已安装</span>
+                </div>
+                <div class="hint">
+                  <a :href="`https://github.com/${r.source}`" target="_blank" rel="noopener noreferrer" class="font-mono text-link">{{ r.source }}</a>
+                  · {{ r.installs.toLocaleString() }} 次安装
+                </div>
+                <div v-if="r.blocked" class="hint text-warning">{{ r.blocked }}</div>
+              </div>
+              <button type="button" class="btn-secondary flex-none" :disabled="!!communityBusy || !!r.blocked" @click="openPreview(r.source, r.skillId)">
+                <span class="codicon" :class="communityBusy === `${r.source}/${r.skillId}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-eye'" />查看
+              </button>
+            </div>
+            <div v-if="!communityResults.length" class="px-4 py-3 hint">没有找到匹配的技能</div>
+          </div>
+        </section>
+      </div>
+
       <!-- Codex:OpenAI 官方技能仓库 openai/skills -->
-      <div v-if="tab === 'codex'" class="max-w-240 p-6 flex flex-col gap-6">
+      <div v-else-if="tab === 'codex'" class="max-w-240 p-6 flex flex-col gap-6">
         <section class="card p-4 flex gap-3">
           <span class="codicon codicon-info text-link text-[18px] mt-0.5" />
           <div class="flex flex-col gap-1.5 text-[12.5px]">
@@ -145,7 +281,7 @@ onMounted(() => {
           </select>
           <span class="hint">{{ codexShown.length }} / {{ codex.skills.length }}</span>
         </div>
-        <div class="card divide-y divide-solid divide-border">
+        <div class="card divide-y divide-x-0 divide-solid divide-border">
           <div v-for="s in codexShown" :key="s.name" class="flex items-start gap-3 px-4 py-2.5">
             <div class="flex-1 min-w-0">
               <div class="flex items-center gap-2">
@@ -229,7 +365,7 @@ onMounted(() => {
         <section class="flex flex-col gap-2">
           <h2 class="section-title m-0">本地技能目录</h2>
           <div class="hint">自己编写的技能,位于设置中的"共享技能目录",随代码仓库的 skills/ 部署。</div>
-          <div class="card divide-y divide-solid divide-border">
+          <div class="card divide-y divide-x-0 divide-solid divide-border">
             <div v-for="s in state?.localSkills" :key="s.id" class="px-4 py-2 text-[12.5px]">
               <span class="font-mono">{{ s.id }}</span>
               <div class="hint line-clamp-2">{{ s.description }}</div>
@@ -274,7 +410,7 @@ onMounted(() => {
               <option v-for="m in state?.marketplaces" :key="m.name" :value="m.name">{{ m.name }}</option>
             </select>
           </div>
-          <div class="card divide-y divide-solid divide-border">
+          <div class="card divide-y divide-x-0 divide-solid divide-border">
             <div v-for="p in available" :key="p.pluginId" class="flex items-start gap-3 px-4 py-2.5">
               <div class="flex-1 min-w-0">
                 <div class="flex items-center gap-2">
@@ -295,6 +431,47 @@ onMounted(() => {
       </div>
     </div>
 
+    <!-- 第三方技能预览:安装前必须查看 -->
+    <div v-if="preview" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="preview = null">
+      <div class="w-[min(860px,92vw)] max-h-[88vh] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2 flex-wrap">
+          <span class="font-600 text-[14px]">{{ preview.name }}</span>
+          <span class="badge bg-code" :class="preview.license === 'open' ? 'text-success' : 'text-warning'">{{ preview.licenseName ?? '许可证无法识别' }}</span>
+          <span v-if="preview.licenseFrom" class="hint">({{ { skill: '技能目录中的 LICENSE', frontmatter: 'SKILL.md 声明', repo: '仓库根目录的 LICENSE' }[preview.licenseFrom] }})</span>
+          <span class="flex-1" />
+          <button type="button" class="icon-btn" title="关闭" @click="preview = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div class="hint">
+          <a :href="`https://github.com/${preview.source}/tree/${preview.sha}/${preview.path}`" target="_blank" rel="noopener noreferrer" class="font-mono text-link">{{ preview.source }}@{{ preview.sha.slice(0, 8) }}/{{ preview.path || '.' }}</a>
+          <template v-if="preview.installed"> · 已安装 {{ preview.installed.sha.slice(0, 8) }}{{ preview.installed.sha === preview.sha ? '(已是最新)' : ',可以更新到此版本' }}</template>
+        </div>
+        <div v-if="preview.license !== 'open'" class="text-error">只能安装开源许可的技能,这个技能不能安装。</div>
+        <div v-if="preview.conflict" class="text-error">{{ preview.conflict }}</div>
+        <div v-if="preview.hasScripts" class="text-warning flex items-center gap-1.5"><span class="codicon codicon-warning" />包含可执行脚本,Agent 可能会在沙箱中运行它们,请检查文件内容。</div>
+        <div class="flex-1 min-h-0 grid grid-cols-[1fr_220px] gap-3">
+          <pre class="m-0 p-3 rounded-md bg-code overflow-auto font-mono text-[12px] whitespace-pre-wrap break-words max-h-[56vh]">{{ preview.skillMd }}</pre>
+          <div class="overflow-auto max-h-[56vh] flex flex-col gap-0.5">
+            <div class="font-600 mb-1">文件({{ preview.files.length }})</div>
+            <div v-for="f in preview.files" :key="f.path" class="flex gap-2 font-mono text-[11.5px]">
+              <span class="truncate flex-1" :title="f.path">{{ f.path }}</span>
+              <span class="hint flex-none">{{ fmtSize(f.size) }}</span>
+            </div>
+          </div>
+        </div>
+        <label class="flex items-center gap-2 cursor-pointer"><input v-model="previewRead" type="checkbox">我已阅读 SKILL.md 与文件列表,了解这是未经审核的第三方内容</label>
+        <div class="flex justify-end gap-2">
+          <button type="button" class="btn-secondary" @click="preview = null">取消</button>
+          <button
+            type="button" class="btn-primary"
+            :disabled="!previewRead || preview.license !== 'open' || !!preview.conflict || preview.installed?.sha === preview.sha || communityBusy === 'install'"
+            @click="installPreview"
+          >
+            <span v-if="communityBusy === 'install'" class="codicon codicon-loading codicon-modifier-spin" />{{ preview.installed ? '更新到此版本' : '安装' }}
+          </button>
+        </div>
+      </div>
+    </div>
+
     <!-- 官方技能安装确认 -->
     <div v-if="confirmInstall" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="confirmInstall = null">
       <div class="w-[560px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">

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

@@ -67,7 +67,8 @@ 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.map(s => s.id) },
+  // 取消"全部"时保留原来全部启用的技能;第三方技能不在"全部"之内
+  set: v => { if (draft.value) draft.value.skills = v ? 'all' : skills.value.filter(s => s.source !== 'community').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)))
@@ -171,15 +172,16 @@ 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 || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
+                <input type="checkbox" class="mt-1" :checked="(skillsAll && s.source !== 'community') || 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 class="block hint truncate">{{ s.description }}</span>
                 </span>
               </label>

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

@@ -13,6 +13,7 @@ import { InteractionService } from './services/interactions'
 import { PluginService } from './services/plugins'
 import { ClaudeLoginManager } from './services/claude-login'
 import { CodexSkillService } from './services/codex-skills'
+import { CommunitySkillService } from './services/community-skills'
 import { RunTokenRegistry } from './mcp-gateway/tokens'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
@@ -41,13 +42,14 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
   const plugins = new PluginService(settings, audit)
   const codexSkills = new CodexSkillService(summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
+  const communitySkills = new CommunitySkillService(summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   const gatewayTokens = new RunTokenRegistry()
   const claudeLogin = new ClaudeLoginManager(() => settings.get().providers.claude.cliPath, summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider(), codex: new CodexProvider(log) }
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, gatewayTokens, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, communitySkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

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

@@ -34,6 +34,9 @@ export const gatewayUrl = () => `http://127.0.0.1:${config.port}/mcp`
 
 export const dbPath = () => path.join(config.dataDir, 'data', 'agent.db')
 
+/** 第三方技能目录(Claude 插件格式,两个引擎共用,ADR-0017) */
+export const communitySkillsDir = () => path.join(config.dataDir, 'data', 'community-skills')
+
 function parseTrustProxy(v: string | undefined): boolean | string[] {
   if (!v || v === 'false') return false
   if (v === 'true') return true

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

@@ -1,7 +1,7 @@
 // 管理 API:仅后台登录态可访问
 import type { FastifyInstance } from 'fastify'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import { AppError } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
@@ -14,6 +14,8 @@ import { providerStatuses } from './v1'
 
 type IdParams = { Params: { id: string } }
 
+const communityRefSchema = z.object({ source: z.string().min(3).max(200), skillId: z.string().min(1).max(100) })
+
 const pluginActionSchema: z.ZodType<PluginAction> = z.union([
   z.object({ action: z.literal('marketplace-add'), source: z.string().trim().min(1).max(300) }),
   z.object({ action: z.enum(['marketplace-remove', 'marketplace-update']), name: z.string().min(1).max(120) }),
@@ -117,19 +119,32 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
       return { settings: next }
     })
-    // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件;Codex = Codex 实际可见的技能
+    // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能;Codex = Codex 实际可见的技能
     g.get<{ Querystring: { engine?: string } }>('/skills', async req => {
-      if (req.query.engine !== 'codex') return app.plugins.skills()
+      if (req.query.engine !== 'codex') return [...await app.plugins.skills(), ...app.communitySkills.skillInfos()]
       const codex = app.providers.codex
       if (!(codex instanceof CodexProvider)) return []
       const infos = await codex.skillInfos(app.settings.get()).catch(() => [])
       return infos.map(s => {
         const [prefix] = s.id.split(':')
-        const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin' : 'local'
-        return { id: s.id, name: s.name.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'official' || source === 'local' ? 'open' : 'unknown' }
+        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' }
       })
     })
 
+    // 第三方技能(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 ?? ''))
+    g.post('/community-skills/preview', async req => {
+      const b = parse(communityRefSchema, req.body)
+      return app.communitySkills.preview(b.source, b.skillId)
+    })
+    g.post('/community-skills/install', async req => {
+      const b = parse(communityRefSchema.extend({ sha: z.string() }), req.body)
+      return app.communitySkills.install(b.source, b.skillId, b.sha)
+    })
+    g.delete<{ Params: { name: string } }>('/community-skills/:name', async req => app.communitySkills.uninstall(req.params.name))
+
     // Codex 官方技能(openai/skills)
     g.get('/codex-skills', async () => app.codexSkills.catalog())
     g.post('/codex-skills/refresh', async () => app.codexSkills.refresh())

+ 20 - 0
apps/server/src/lib/skill-files.ts

@@ -0,0 +1,20 @@
+// SKILL.md 读取工具:Claude 插件、Codex 官方技能、第三方技能共用
+
+/** 读取 SKILL.md 头部的 YAML 字段(只处理单层键值,支持 > / | 多行写法) */
+export function frontmatter(md: string): Record<string, string> {
+  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
+  const out: Record<string, string> = {}
+  const lines = m?.[1]?.split(/\r?\n/) ?? []
+  for (let i = 0; i < lines.length; i++) {
+    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(lines[i]!)
+    if (!kv) continue
+    let value = kv[2]!.trim()
+    if (/^[>|][+-]?$/.test(value)) {
+      const block: string[] = []
+      while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]!) || lines[i + 1] === '')) block.push(lines[++i]!.trim())
+      value = block.join(value.startsWith('>') ? ' ' : '\n').trim()
+    }
+    out[kv[1]!] = value.replace(/^["']|["']$/g, '').trim()
+  }
+  return out
+}

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

@@ -9,7 +9,13 @@ export const LOCAL_PLUGIN_FALLBACK = 'agent-skills'
  * 运行时加载的插件。skills 中的 id 是后台展示和 Profile 中保存的 ID(如 document-skills:xlsx),
  * loadId 是 CLI 实际使用的 ID:按路径加载时,插件名取自 plugin.json,没有清单时取目录名(如 dbd4588f9e10:xlsx)
  */
-export interface PluginRef { name: string; path: string; skills: { id: string; loadId: string }[] }
+export interface PluginRef {
+  name: string
+  path: string
+  skills: { id: string; loadId: string }[]
+  /** 需要在 Profile 中单独勾选的插件(第三方技能),选择"全部技能"时不包含 */
+  optIn?: boolean
+}
 
 export function listSkills(dir: string): string[] {
   try {
@@ -40,7 +46,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(available.map(s => s.id))
+    ? new Set(plugins.filter(p => !p.optIn).flatMap(p => p.skills.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

@@ -2,6 +2,7 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import { communitySkillsDir } from '../../config'
 import { checkAccess } from '../../services/access-policy'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
 import { bundledCodexPath, codexHome } from './cli'
@@ -75,9 +76,9 @@ export class CodexProvider implements AgentProvider {
         for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`))
       })
       const init = await client.start(CLIENT_INFO) as { userAgent?: string }
-      // 本地技能目录(与 Claude 共用,自写技能)作为额外技能根目录
-      const localSkills = path.join(settings.paths.skillsDir, 'skills')
-      if (fs.existsSync(localSkills)) await client.request('skills/extraRoots/set', { extraRoots: [localSkills] }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败'))
+      // 本地技能目录(自写技能)与第三方技能目录与 Claude 共用,作为额外技能根目录
+      const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), '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
       this.client = client

+ 10 - 3
apps/server/src/providers/codex/skills.ts

@@ -1,6 +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 type { SkillMetadata } from './protocol/v2'
 
 /** 服务场景下始终禁用的系统技能:会联网下载或写入 Codex 自身目录 */
@@ -10,11 +11,13 @@ export const ALWAYS_DISABLED = new Set(['skill-installer', 'skill-creator', 'plu
  * 展示 ID:
  * - 系统技能 → codex-system:<name>
  * - 从 openai/skills 安装的技能(CODEX_HOME/skills 下有标记文件)→ openai-skills:<name>
- * - 本地技能目录(extraRoots,名称已带插件前缀)→ 原样,如 agent-skills:report-conventions
+ * - 本地技能目录与第三方技能目录(extraRoots,Codex 按 plugin.json 加了前缀)→ 原样,如 agent-skills:report-conventions
  * - 其他用户技能 → codex-user:<name>
  */
 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))}`
   if (s.name.includes(':')) return s.name
   const managedRoot = path.join(codexHome, 'skills')
   const dir = path.dirname(s.path)
@@ -22,10 +25,14 @@ export function skillDisplayId(s: Pick<SkillMetadata, 'name' | 'scope' | 'path'>
   return `codex-user:${s.name}`
 }
 
-/** 计算需要在本次对话中禁用的技能(按路径选择) */
+/** 计算需要在本次对话中禁用的技能(按路径选择);选择"全部技能"时第三方技能仍需单独勾选 */
 export function skillsToDisable(all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string): { path: string; enabled: false }[] {
   const wanted = selected === 'all' ? null : new Set(selected.map(s => (s.includes(':') ? s : `${localPlugin}:${s}`)))
   return all
-    .filter(s => ALWAYS_DISABLED.has(s.name) || (wanted !== null && !wanted.has(skillDisplayId(s, codexHome))))
+    .filter(s => {
+      if (ALWAYS_DISABLED.has(s.name)) return true
+      const id = skillDisplayId(s, codexHome)
+      return wanted === null ? id.startsWith(`${COMMUNITY_SKILL_PLUGIN}:`) : !wanted.has(id)
+    })
     .map(s => ({ path: s.path, enabled: false as const }))
 }

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

@@ -11,7 +11,7 @@ import {
   type PathCheck,
   type ResolvedAccessDto,
 } from '@agent-server/shared'
-import { config } from '../config'
+import { communitySkillsDir, config } from '../config'
 import { expandPath, hasGlob, isWithin, realpathLoose } from '../lib/paths'
 import { codexHome } from '../providers/codex/cli'
 
@@ -57,6 +57,7 @@ export function resolveAccess(opts: {
   readable.add(settings.paths.matplotlibrc)
   readable.add(settings.paths.nodeModules)
   readable.add(settings.paths.skillsDir)
+  readable.add(communitySkillsDir())
 
   for (const id of opts.selectedKnowledgeRootIds) {
     const root = roots.get(id)

+ 1 - 9
apps/server/src/services/codex-skills.ts

@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
 import type { LicenseKind } from '@agent-server/shared'
 import { config } from '../config'
 import { AppError, badRequest, notFound } from '../lib/errors'
+import { frontmatter } from '../lib/skill-files'
 import { codexHome } from '../providers/codex/cli'
 
 const execFileP = promisify(execFile)
@@ -177,15 +178,6 @@ function readText(f: string): string | null {
   }
 }
 
-function frontmatter(md: string): Record<string, string> {
-  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
-  const out: Record<string, string> = {}
-  for (const line of m?.[1]?.split(/\r?\n/) ?? []) {
-    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
-    if (kv) out[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, '').trim()
-  }
-  return out
-}
 
 function detectLicense(dir: string): { kind: LicenseKind; name: string | null } {
   const f = safeFiles(dir).find(n => /^licen[cs]e/i.test(n))

+ 320 - 0
apps/server/src/services/community-skills.ts

@@ -0,0 +1,320 @@
+// 第三方技能(ADR-0017):在 skills.sh 目录中搜索,从 GitHub 按固定提交下载,安装到第三方技能目录。
+// 目录采用 Claude 插件格式(.claude-plugin/plugin.json + skills/<name>),Claude 按路径加载为插件,
+// Codex 通过 skills/extraRoots/set 加入;两个引擎显示的技能 ID 都是 community-skills:<name>。
+// 只允许开源许可;安装前必须先预览 SKILL.md;默认不启用,需要在 Profile 中单独勾选。
+import { execFile } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import {
+  COMMUNITY_SKILL_PLUGIN, type CommunitySearchResultDto, type CommunitySkillDto, type CommunitySkillPreviewDto, type LicenseKind, type SkillInfo,
+} from '@agent-server/shared'
+import { communitySkillsDir, config } from '../config'
+import { AppError, badRequest, notFound } from '../lib/errors'
+import { frontmatter } from '../lib/skill-files'
+import type { PluginRef } from '../providers/claude/skills'
+
+const execFileP = promisify(execFile)
+
+const MARKER = '.agent-server.json'
+const MAX_TARBALL = 50 * 1048576
+const MAX_SKILL_FILES = 500
+const MAX_SKILL_BYTES = 20 * 1048576
+const MAX_SKILL_MD = 200_000
+const SEARCH_DEPTH = 6
+const SKIP_DIRS = new Set(['.git', 'node_modules', '.github'])
+const SCRIPT_EXT = /\.(py|sh|bash|js|mjs|cjs|ts|rb|pl|ps1|exe|bin)$/i
+/** 官方技能仓库按引擎在各自的页面安装,不作为第三方技能(官方技能不交叉使用,ADR-0016) */
+const BLOCKED_SOURCES: Record<string, string> = {
+  'anthropics/skills': 'Anthropic 官方技能请在 "Claude · Anthropic 官方技能" 中安装,只提供给 Claude',
+  'openai/skills': 'OpenAI 官方技能请在 "Codex · OpenAI 官方技能" 中安装,只提供给 Codex',
+}
+/** GitHub owner/repo;不允许以 . 开头(防止 ../ 之类的路径) */
+const REPO_RE = /^[\w-][\w.-]{0,99}\/[\w-][\w.-]{0,99}$/
+const NAME_RE = /^[a-z0-9][\w.-]{0,63}$/i
+
+interface Marker { source: string; skillId: string; path: string; sha: string; installedAt: string; license: string | null }
+
+type Fetcher = (url: string) => Promise<Response>
+
+export class CommunitySkillService {
+  constructor(
+    private audit: (summary: string) => void,
+    private fetcher: Fetcher = (url: string) => fetch(url, { headers: { 'user-agent': 'agent-server', accept: 'application/json' }, signal: AbortSignal.timeout(120_000) }),
+    private roots = { cache: path.join(config.dataDir, 'data', 'skill-catalogs', 'github'), plugin: communitySkillsDir() },
+  ) {
+    this.ensurePlugin()
+  }
+
+  private get skillsRoot() {
+    return path.join(this.roots.plugin, 'skills')
+  }
+
+  /** 插件清单:Claude 用它作为技能前缀,Codex 也据此给 extraRoots 中的技能加前缀 */
+  private ensurePlugin() {
+    fs.mkdirSync(this.skillsRoot, { recursive: true, mode: 0o755 })
+    const manifest = path.join(this.roots.plugin, '.claude-plugin', 'plugin.json')
+    if (!fs.existsSync(manifest)) {
+      fs.mkdirSync(path.dirname(manifest), { recursive: true })
+      fs.writeFileSync(manifest, JSON.stringify({ name: COMMUNITY_SKILL_PLUGIN, version: '0.1.0', description: '第三方技能(由 AgentServer 管理)' }, null, 2))
+    }
+  }
+
+  async search(q: string): Promise<CommunitySearchResultDto[]> {
+    const query = q.trim().slice(0, 100)
+    if (query.length < 2) return []
+    const r = await this.fetcher(`https://skills.sh/api/search?q=${encodeURIComponent(query)}`)
+    if (!r.ok) throw new AppError(502, 'search_failed', `skills.sh 搜索失败(HTTP ${r.status})`)
+    const body = (await r.json()) as { skills?: { source?: string; skillId?: string; name?: string; installs?: number }[] }
+    const installed = this.installedMarkers()
+    return (body.skills ?? [])
+      .filter((s): s is { source: string; skillId: string; name?: string; installs?: number } => typeof s.source === 'string' && REPO_RE.test(s.source) && typeof s.skillId === 'string')
+      .map(s => ({
+        source: s.source, skillId: s.skillId, name: s.name ?? s.skillId, installs: s.installs ?? 0,
+        installed: [...installed.values()].some(m => m.source === s.source && m.skillId === s.skillId),
+        blocked: BLOCKED_SOURCES[s.source.toLowerCase()] ?? null,
+      }))
+  }
+
+  /** 下载仓库当前版本并定位技能,返回安装前必须查看的内容 */
+  async preview(source: string, skillId: string): Promise<CommunitySkillPreviewDto> {
+    const { sha, dir } = await this.snapshot(source)
+    return this.describe(source, skillId, sha, dir)
+  }
+
+  /** 按预览时的提交安装(预览后仓库有新提交也不会装上未看过的内容) */
+  install(source: string, skillId: string, sha: string): CommunitySkillDto[] {
+    checkSource(source)
+    if (!/^[0-9a-f]{40}$/.test(sha)) throw badRequest('提交号格式不正确')
+    const dir = this.snapshotDir(source, sha)
+    if (!fs.existsSync(dir)) throw badRequest('请先预览技能内容再安装')
+    const p = this.describe(source, skillId, sha, dir)
+    if (p.license !== 'open') throw badRequest(`技能 ${skillId} 的许可为"${p.licenseName ?? '无法识别'}",只能安装开源许可的技能`)
+    if (p.conflict) throw badRequest(p.conflict)
+    const src = path.join(dir, p.path)
+    const dest = path.join(this.skillsRoot, p.name)
+    const tmp = `${dest}.installing`
+    fs.rmSync(tmp, { recursive: true, force: true })
+    copyRegularFiles(src, tmp)
+    const marker: Marker = { source, skillId, path: p.path, sha, installedAt: new Date().toISOString(), license: p.licenseName }
+    fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify(marker, null, 2))
+    fs.rmSync(dest, { recursive: true, force: true })
+    fs.renameSync(tmp, dest)
+    this.audit(`安装第三方技能 ${p.name}(${source}@${sha.slice(0, 8)}:${p.path},${p.licenseName})`)
+    return this.list()
+  }
+
+  uninstall(name: string): CommunitySkillDto[] {
+    const dest = path.join(this.skillsRoot, name)
+    if (!NAME_RE.test(name) || !fs.existsSync(path.join(dest, MARKER))) throw notFound(`第三方技能 ${name}`)
+    fs.rmSync(dest, { recursive: true, force: true })
+    this.audit(`卸载第三方技能 ${name}`)
+    return this.list()
+  }
+
+  list(): CommunitySkillDto[] {
+    return [...this.installedMarkers()].map(([name, m]) => {
+      const fm = frontmatter(readText(path.join(this.skillsRoot, name, 'SKILL.md')) ?? '')
+      return {
+        id: `${COMMUNITY_SKILL_PLUGIN}:${name}`, name, description: fm.description ?? '', source: m.source, skillId: m.skillId,
+        path: m.path, sha: m.sha, license: m.license, installedAt: m.installedAt,
+      }
+    }).sort((a, b) => a.name.localeCompare(b.name))
+  }
+
+  skillInfos(): SkillInfo[] {
+    return this.list().map(s => ({ id: s.id, name: s.name, description: s.description, plugin: COMMUNITY_SKILL_PLUGIN, source: 'community', license: 'open' }))
+  }
+
+  /** Claude 运行时按路径加载;optIn 表示 Profile 选"全部技能"时不包含 */
+  pluginRef(): PluginRef | null {
+    const names = [...this.installedMarkers().keys()]
+    if (!names.length) return null
+    const skills = names.map(n => ({ id: `${COMMUNITY_SKILL_PLUGIN}:${n}`, loadId: `${COMMUNITY_SKILL_PLUGIN}:${n}` }))
+    return { name: COMMUNITY_SKILL_PLUGIN, path: this.roots.plugin, skills, optIn: true }
+  }
+
+  // ---- 内部 ----
+
+  private snapshotDir(source: string, sha: string) {
+    return path.join(this.roots.cache, source.replace('/', '__'), sha)
+  }
+
+  /** 取默认分支最新提交并缓存仓库快照;同一仓库只保留最近一次的快照 */
+  private async snapshot(source: string): Promise<{ sha: string; dir: string }> {
+    checkSource(source)
+    const r = await this.fetcher(`https://api.github.com/repos/${source}/commits/HEAD`)
+    if (r.status === 404) throw notFound(`GitHub 仓库 ${source}`)
+    if (!r.ok) throw new AppError(502, 'fetch_failed', `无法获取 ${source} 的最新版本(HTTP ${r.status})`)
+    const sha = ((await r.json()) as { sha?: string }).sha ?? ''
+    if (!/^[0-9a-f]{40}$/.test(sha)) throw new AppError(502, 'fetch_failed', 'GitHub 返回的提交号格式不正确')
+    const dir = this.snapshotDir(source, sha)
+    if (fs.existsSync(dir)) return { sha, dir }
+
+    const tar = await this.fetcher(`https://codeload.github.com/${source}/tar.gz/${sha}`)
+    if (!tar.ok) throw new AppError(502, 'fetch_failed', `下载仓库失败(HTTP ${tar.status})`)
+    const len = Number(tar.headers.get('content-length') ?? 0)
+    if (len > MAX_TARBALL) throw badRequest(`仓库压缩包超过 ${MAX_TARBALL / 1048576} MB,不支持`)
+    const buf = Buffer.from(await tar.arrayBuffer())
+    if (buf.length > MAX_TARBALL) throw badRequest(`仓库压缩包超过 ${MAX_TARBALL / 1048576} MB,不支持`)
+    const repoDir = path.dirname(dir)
+    fs.rmSync(repoDir, { recursive: true, force: true })
+    const tmp = `${dir}.extracting`
+    fs.mkdirSync(tmp, { recursive: true })
+    const file = path.join(repoDir, `${sha}.tar.gz`)
+    fs.writeFileSync(file, buf)
+    try {
+      await execFileP('tar', ['-xzf', file, '-C', tmp, '--strip-components=1', '--no-same-owner', '--no-same-permissions'])
+      fs.renameSync(tmp, dir)
+    } catch (err) {
+      fs.rmSync(tmp, { recursive: true, force: true })
+      throw new AppError(502, 'fetch_failed', `解压仓库失败:${(err as Error).message.slice(0, 200)}`)
+    } finally {
+      fs.rmSync(file, { force: true })
+    }
+    return { sha, dir }
+  }
+
+  private describe(source: string, skillId: string, sha: string, dir: string): CommunitySkillPreviewDto {
+    const rel = findSkill(dir, skillId)
+    if (rel === null) throw notFound(`仓库 ${source} 中的技能 ${skillId}`)
+    const skillDir = path.join(dir, rel)
+    const skillMd = readText(path.join(skillDir, 'SKILL.md'))?.slice(0, MAX_SKILL_MD) ?? ''
+    const fm = frontmatter(skillMd)
+    const name = fm.name && NAME_RE.test(fm.name) ? fm.name : skillId
+    if (!NAME_RE.test(name)) throw badRequest(`技能名称 ${name} 格式不正确`)
+    const files = listFiles(skillDir)
+    const lic = detectLicense(skillDir, fm.license, dir)
+    const installed = this.installedMarkers().get(name)
+    const sameSource = installed && installed.source === source && installed.skillId === skillId
+    const unmanaged = !installed && fs.existsSync(path.join(this.skillsRoot, name))
+    return {
+      source, skillId, name, description: fm.description ?? '', sha, path: rel, skillMd, files,
+      hasScripts: files.some(f => SCRIPT_EXT.test(f.path)),
+      license: lic.kind, licenseName: lic.name, licenseFrom: lic.from,
+      installed: sameSource ? { sha: installed.sha } : null,
+      conflict: installed && !sameSource
+        ? `已经从 ${installed.source} 安装了同名技能 ${name},请先卸载`
+        : unmanaged ? `第三方技能目录中已有 ${name},且不是由 AgentServer 安装的` : null,
+    }
+  }
+
+  private installedMarkers(): Map<string, Marker> {
+    const out = new Map<string, Marker>()
+    for (const d of safeEntries(this.skillsRoot)) {
+      if (!d.isDirectory() || d.name.endsWith('.installing')) continue
+      try {
+        out.set(d.name, JSON.parse(fs.readFileSync(path.join(this.skillsRoot, d.name, MARKER), 'utf8')) as Marker)
+      } catch {
+        // 不是由 AgentServer 安装的
+      }
+    }
+    return out
+  }
+}
+
+function checkSource(source: string) {
+  if (!REPO_RE.test(source)) throw badRequest('仓库格式应为 owner/repo')
+  const blocked = BLOCKED_SOURCES[source.toLowerCase()]
+  if (blocked) throw badRequest(blocked)
+}
+
+/**
+ * 在仓库中定位技能目录(返回相对路径,根目录为 '')。
+ * 优先 SKILL.md 中 name 与 skillId 相同的目录,其次目录名相同;多个匹配时取路径最短的。
+ */
+function findSkill(root: string, skillId: string): string | null {
+  const byName: string[] = []
+  const byDir: string[] = []
+  const walk = (dir: string, depth: number) => {
+    const entries = safeEntries(dir)
+    const md = entries.find(e => e.name === 'SKILL.md' && e.isFile())
+    if (md) {
+      const rel = path.relative(root, dir)
+      const fm = frontmatter(readText(path.join(dir, 'SKILL.md'))?.slice(0, 4000) ?? '')
+      if (fm.name === skillId) byName.push(rel)
+      else if (path.basename(dir) === skillId) byDir.push(rel)
+    }
+    if (depth >= SEARCH_DEPTH) return
+    for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), depth + 1)
+  }
+  walk(root, 0)
+  const pick = (l: string[]) => l.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b))[0]
+  return pick(byName) ?? pick(byDir) ?? null
+}
+
+/** 技能目录中的普通文件(跳过符号链接等特殊文件) */
+function listFiles(dir: string): { path: string; size: number }[] {
+  const out: { path: string; size: number }[] = []
+  const walk = (d: string) => {
+    for (const e of safeEntries(d)) {
+      const abs = path.join(d, e.name)
+      if (e.isDirectory()) {
+        if (!SKIP_DIRS.has(e.name)) walk(abs)
+      } else if (e.isFile()) {
+        out.push({ path: path.relative(dir, abs), size: fs.statSync(abs).size })
+      }
+    }
+  }
+  walk(dir)
+  // SKILL.md 排在最前
+  return out.sort((a, b) => Number(b.path === 'SKILL.md') - Number(a.path === 'SKILL.md') || a.path.localeCompare(b.path))
+}
+
+/** 只复制普通文件和目录:仓库中的符号链接可能指向服务器上的其他文件 */
+function copyRegularFiles(src: string, dest: string) {
+  const files = listFiles(src)
+  if (files.length > MAX_SKILL_FILES) throw badRequest(`技能包含 ${files.length} 个文件,超过 ${MAX_SKILL_FILES} 个的上限`)
+  const total = files.reduce((n, f) => n + f.size, 0)
+  if (total > MAX_SKILL_BYTES) throw badRequest(`技能大小超过 ${MAX_SKILL_BYTES / 1048576} MB 上限`)
+  for (const f of files) {
+    const to = path.join(dest, f.path)
+    fs.mkdirSync(path.dirname(to), { recursive: true })
+    fs.copyFileSync(path.join(src, f.path), to)
+  }
+}
+
+const OPEN_SPDX = /^(MIT|MIT-0|Apache[- ]?(License[- ])?2(\.0)?|BSD-[23]-Clause|0BSD|ISC|MPL-2\.0|Unlicense|CC0-1\.0|CC-BY-4\.0)$/i
+
+/** 许可证:技能目录中的 LICENSE 优先,其次 SKILL.md 的 license 字段,最后是仓库根目录的 LICENSE */
+function detectLicense(skillDir: string, declared: string | undefined, repoDir: string): { kind: LicenseKind; name: string | null; from: CommunitySkillPreviewDto['licenseFrom'] } {
+  const own = licenseFile(skillDir)
+  if (own) return { ...own, from: 'skill' }
+  if (declared && OPEN_SPDX.test(declared.trim())) return { kind: 'open', name: declared.trim(), from: 'frontmatter' }
+  if (declared && /proprietary|all rights reserved/i.test(declared)) return { kind: 'restricted', name: '专有许可', from: 'frontmatter' }
+  const repo = skillDir === repoDir ? null : licenseFile(repoDir)
+  if (repo) return { ...repo, from: 'repo' }
+  return { kind: 'unknown', name: declared ? declared.slice(0, 60) : null, from: declared ? 'frontmatter' : null }
+}
+
+function licenseFile(dir: string): { kind: LicenseKind; name: string | null } | null {
+  const f = safeEntries(dir).find(e => e.isFile() && /^(licen[cs]e|copying)(\.|$)/i.test(e.name))
+  if (!f) return null
+  const text = readText(path.join(dir, f.name))?.slice(0, 6000) ?? ''
+  if (/agreement\s+with\s+Anthropic|proprietary|internal evaluation|governed by/i.test(text)) return { kind: 'restricted', name: '专有许可或第三方条款' }
+  if (/Apache License/i.test(text)) return { kind: 'open', name: 'Apache-2.0' }
+  if (/MIT License|Permission is hereby granted, free of charge/i.test(text)) return { kind: 'open', name: 'MIT' }
+  if (/Redistribution and use in source and binary forms/i.test(text)) return { kind: 'open', name: 'BSD' }
+  if (/ISC License|Permission to use, copy, modify, and\/or distribute/i.test(text)) return { kind: 'open', name: 'ISC' }
+  if (/Mozilla Public License/i.test(text)) return { kind: 'open', name: 'MPL-2.0' }
+  if (/free and unencumbered software/i.test(text)) return { kind: 'open', name: 'Unlicense' }
+  if (/CC0 1\.0|Creative Commons Zero/i.test(text)) return { kind: 'open', name: 'CC0-1.0' }
+  return { kind: 'unknown', name: null }
+}
+
+function safeEntries(dir: string): fs.Dirent[] {
+  try {
+    return fs.readdirSync(dir, { withFileTypes: true })
+  } catch {
+    return []
+  }
+}
+
+function readText(f: string): string | null {
+  try {
+    // 只读普通文件,不跟随符号链接
+    return fs.lstatSync(f).isFile() ? fs.readFileSync(f, 'utf8') : null
+  } catch {
+    return null
+  }
+}

+ 1 - 9
apps/server/src/services/plugins.ts

@@ -7,6 +7,7 @@ import type {
   AvailablePluginDto, InstalledPluginDto, LicenseKind, MarketplaceDto, PluginAction, PluginStateDto, SkillInfo,
 } from '@agent-server/shared'
 import { badRequest } from '../lib/errors'
+import { frontmatter } from '../lib/skill-files'
 import { listSkills, LOCAL_PLUGIN_FALLBACK, type PluginRef } from '../providers/claude/skills'
 import { runClaudeCli } from '../providers/claude/cli'
 import type { AuditService } from './catalog'
@@ -172,15 +173,6 @@ function declaredSkills(installPath: string, plugin: string): Set<string> | null
   }
 }
 
-function frontmatter(md: string): Record<string, string> {
-  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
-  const out: Record<string, string> = {}
-  for (const line of m?.[1]?.split(/\r?\n/) ?? []) {
-    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
-    if (kv) out[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, '').trim()
-  }
-  return out
-}
 
 /** 识别许可证:Anthropic 专有许可(源码可见、仅限在其服务内使用)或常见开源许可 */
 function detectLicense(dir: string): { kind: LicenseKind; summary: string | null } {

+ 10 - 2
apps/server/src/services/runs.ts

@@ -16,6 +16,7 @@ import { parse, type AuditService, type KnowledgeService, type ProfileService }
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
 import type { InteractionService } from './interactions'
+import type { CommunitySkillService } from './community-skills'
 import type { PluginService } from './plugins'
 import type { RunTokenRegistry } from '../mcp-gateway/tokens'
 import { gatewayUrl } from '../config'
@@ -60,6 +61,7 @@ export class RunManager {
       interactions: InteractionService
       audit: AuditService
       plugins: PluginService
+      communitySkills: CommunitySkillService
       gatewayTokens: RunTokenRegistry
       providers: Partial<Record<ProviderId, AgentProvider>>
     },
@@ -195,8 +197,8 @@ export class RunManager {
         allowedTools: profile.allowedTools,
         disallowedTools: profile.disallowedTools,
         skills: profile.skills,
-        // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)
-        plugins: session.provider === 'claude' ? await deps.plugins.loadable().catch(() => []) : [],
+        // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)。第三方技能 Codex 经 extraRoots 加载(ADR-0017)
+        plugins: session.provider === 'claude' ? await this.claudePlugins() : [],
         input: await this.buildInput(session, text, attachmentIds),
         access,
         settings,
@@ -264,6 +266,12 @@ export class RunManager {
     this.deps.events.append(a.sessionId, a.runId, `run.${status}`, p)
   }
 
+  /** Claude 运行时加载的插件:已启用的插件 + 第三方技能目录 */
+  private async claudePlugins() {
+    const community = this.deps.communitySkills.pluginRef()
+    return [...await this.deps.plugins.loadable().catch(() => []), ...(community ? [community] : [])]
+  }
+
   private async buildInput(session: SessionRow, text: string, attachmentIds: string[]): Promise<RunInput> {
     const input: RunInput = { text, images: [], attachments: [] }
     for (const f of this.deps.files.getMany(session.id, attachmentIds)) {

+ 158 - 0
apps/server/test/community-skills.test.ts

@@ -0,0 +1,158 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { resolveSkills } from '../src/providers/claude/skills'
+import { skillsToDisable } from '../src/providers/codex/skills'
+import { CommunitySkillService } from '../src/services/community-skills'
+import { frontmatter } from '../src/lib/skill-files'
+
+const SHA1 = 'c'.repeat(40)
+const SHA2 = 'd'.repeat(40)
+
+/** 构造与 GitHub codeload 相同格式的仓库快照(顶层目录 repo-<sha>/) */
+function tarball(sha: string, files: Record<string, string>, links: Record<string, string> = {}): Buffer {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'crepo-'))
+  const top = path.join(root, `repo-${sha}`)
+  for (const [rel, body] of Object.entries(files)) {
+    fs.mkdirSync(path.dirname(path.join(top, rel)), { recursive: true })
+    fs.writeFileSync(path.join(top, rel), body)
+  }
+  for (const [rel, target] of Object.entries(links)) fs.symlinkSync(target, path.join(top, rel))
+  const out = path.join(root, 'repo.tar.gz')
+  execFileSync('tar', ['-czf', out, '-C', root, `repo-${sha}`])
+  return fs.readFileSync(out)
+}
+
+const skill = (name: string, extra = '') => `---\nname: ${name}\ndescription: >-\n  ${name} does\n  things\n${extra}---\nbody\n`
+
+describe('CommunitySkillService', () => {
+  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'community-'))
+  const roots = { cache: path.join(base, 'cache'), plugin: path.join(base, 'community-skills') }
+  const heads: Record<string, string> = { 'acme/tools': SHA1, 'acme/closed': SHA1, 'other/tools': SHA1 }
+  const repos: Record<string, Buffer> = {
+    [`acme/tools@${SHA1}`]: tarball(SHA1, {
+      'LICENSE': 'MIT License\n\nPermission is hereby granted, free of charge',
+      'skills/csv-clean/SKILL.md': skill('csv-clean'),
+      'skills/csv-clean/scripts/clean.py': 'print(1)',
+      'skills/renamed-dir/SKILL.md': skill('chart-kit'),
+      'docs/csv-clean/SKILL.md': skill('csv-clean-docs'),
+    }, { 'skills/csv-clean/passwd': '/etc/passwd' }),
+    [`acme/tools@${SHA2}`]: tarball(SHA2, {
+      'LICENSE': 'MIT License',
+      'skills/csv-clean/SKILL.md': skill('csv-clean', 'version: 2\n'),
+    }),
+    [`acme/closed@${SHA1}`]: tarball(SHA1, {
+      'skills/secret/SKILL.md': skill('secret', 'license: Complete terms in LICENSE.txt\n'),
+      'skills/secret/LICENSE.txt': '© Anthropic, PBC. All rights reserved. Use is governed by your agreement with Anthropic.',
+      'skills/nolicense/SKILL.md': skill('nolicense'),
+      'skills/declared/SKILL.md': skill('declared', 'license: Apache-2.0\n'),
+    }),
+    [`other/tools@${SHA1}`]: tarball(SHA1, { 'LICENSE': 'Apache License\nVersion 2.0', 'csv-clean/SKILL.md': skill('csv-clean') }),
+  }
+  const fetcher = async (url: string) => {
+    if (url.startsWith('https://skills.sh/api/search')) {
+      return new Response(JSON.stringify({ skills: [
+        { id: 'acme/tools/csv-clean', source: 'acme/tools', skillId: 'csv-clean', name: 'csv-clean', installs: 9 },
+        { id: 'anthropics/skills/pdf', source: 'anthropics/skills', skillId: 'pdf', name: 'pdf', installs: 1 },
+        { id: 'bad', source: '../x', skillId: 'x' },
+      ] }))
+    }
+    let m = /repos\/([^/]+\/[^/]+)\/commits\/HEAD$/.exec(url)
+    if (m) return heads[m[1]!] ? new Response(JSON.stringify({ sha: heads[m[1]!] })) : new Response('{}', { status: 404 })
+    m = /codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/(\w+)$/.exec(url)
+    const buf = m && repos[`${m[1]}@${m[2]}`]
+    return buf ? new Response(buf) : new Response('nf', { status: 404 })
+  }
+  const audits: string[] = []
+  const svc = new CommunitySkillService(s => audits.push(s), fetcher, roots)
+
+  it('初始化插件目录:plugin.json 名称为 community-skills', () => {
+    expect(JSON.parse(fs.readFileSync(path.join(roots.plugin, '.claude-plugin/plugin.json'), 'utf8')).name).toBe('community-skills')
+    expect(svc.pluginRef()).toBeNull()
+  })
+
+  it('搜索:过滤非法来源,标记官方仓库', async () => {
+    const r = await svc.search('csv')
+    expect(r.map(s => [s.source, s.skillId, s.blocked !== null])).toEqual([['acme/tools', 'csv-clean', false], ['anthropics/skills', 'pdf', true]])
+    expect(await svc.search('a')).toEqual([])
+  })
+
+  it('预览:定位技能目录、识别许可证与脚本、忽略符号链接', async () => {
+    const p = await svc.preview('acme/tools', 'csv-clean')
+    expect(p).toMatchObject({ name: 'csv-clean', path: 'skills/csv-clean', sha: SHA1, license: 'open', licenseName: 'MIT', licenseFrom: 'repo', hasScripts: true, installed: null, conflict: null })
+    expect(p.description).toBe('csv-clean does things')
+    expect(p.files.map(f => f.path)).toEqual(['SKILL.md', 'scripts/clean.py'])
+    // 目录名与技能名不同时按 SKILL.md 的 name 匹配
+    expect((await svc.preview('acme/tools', 'chart-kit')).path).toBe('skills/renamed-dir')
+    await expect(svc.preview('acme/tools', 'missing')).rejects.toThrow(/不存在/)
+    await expect(svc.preview('anthropics/skills', 'pdf')).rejects.toThrow(/Claude/)
+    await expect(svc.preview('a/b; rm', 'x')).rejects.toThrow(/owner\/repo/)
+  })
+
+  it('许可证:专有许可与无法识别的许可拒绝安装;SKILL.md 声明开源许可可以安装', async () => {
+    const secret = await svc.preview('acme/closed', 'secret')
+    expect([secret.license, secret.licenseFrom]).toEqual(['restricted', 'skill'])
+    expect(() => svc.install('acme/closed', 'secret', SHA1)).toThrow(/只能安装开源许可/)
+    expect((await svc.preview('acme/closed', 'nolicense')).license).toBe('unknown')
+    expect(() => svc.install('acme/closed', 'nolicense', SHA1)).toThrow(/无法识别/)
+    expect(await svc.preview('acme/closed', 'declared')).toMatchObject({ license: 'open', licenseName: 'Apache-2.0', licenseFrom: 'frontmatter' })
+  })
+
+  it('安装:只复制普通文件并写入标记;必须先预览', async () => {
+    expect(() => svc.install('acme/tools', 'csv-clean', SHA2)).toThrow(/先预览/)
+    const list = svc.install('acme/tools', 'csv-clean', SHA1)
+    expect(list).toMatchObject([{ id: 'community-skills:csv-clean', name: 'csv-clean', source: 'acme/tools', sha: SHA1, license: 'MIT' }])
+    const dir = path.join(roots.plugin, 'skills/csv-clean')
+    expect(fs.existsSync(path.join(dir, 'scripts/clean.py'))).toBe(true)
+    expect(fs.existsSync(path.join(dir, 'passwd'))).toBe(false)
+    expect(svc.pluginRef()).toEqual({ name: 'community-skills', path: roots.plugin, optIn: true, skills: [{ id: 'community-skills:csv-clean', loadId: 'community-skills:csv-clean' }] })
+    expect((await svc.search('csv'))[0]!.installed).toBe(true)
+  })
+
+  it('同名冲突;仓库更新后按新提交升级;卸载', async () => {
+    const other = await svc.preview('other/tools', 'csv-clean')
+    expect(other.conflict).toMatch(/acme\/tools/)
+    expect(() => svc.install('other/tools', 'csv-clean', SHA1)).toThrow(/请先卸载/)
+
+    heads['acme/tools'] = SHA2
+    const p = await svc.preview('acme/tools', 'csv-clean')
+    expect(p).toMatchObject({ sha: SHA2, installed: { sha: SHA1 } })
+    expect(svc.install('acme/tools', 'csv-clean', SHA2)[0]!.sha).toBe(SHA2)
+    // 同一仓库只保留最新快照
+    expect(fs.readdirSync(path.join(roots.cache, 'acme__tools'))).toEqual([SHA2])
+
+    expect(() => svc.uninstall('../x')).toThrow()
+    expect(svc.uninstall('csv-clean')).toEqual([])
+    expect(audits.filter(a => a.startsWith('安装第三方技能'))).toHaveLength(2)
+  })
+})
+
+describe('第三方技能需要在 Profile 中单独启用', () => {
+  it('Claude:all 不包含 optIn 插件,显式选择时加载', () => {
+    const plugins = [
+      { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:r', loadId: 'agent-skills:r' }] },
+      { name: 'community-skills', path: '/c', optIn: true, skills: [{ id: 'community-skills:x', loadId: 'community-skills:x' }] },
+    ]
+    expect(resolveSkills('all', plugins)).toEqual({ plugins: [{ type: 'local', path: '/a' }], skills: ['agent-skills:r'] })
+    expect(resolveSkills(['community-skills:x'], plugins)).toEqual({ plugins: [{ type: 'local', path: '/c' }], skills: ['community-skills:x'] })
+  })
+
+  it('Codex:all 禁用第三方技能,显式选择时保留', () => {
+    const all = [
+      { name: 'agent-skills:r', path: '/a/r/SKILL.md' },
+      { name: 'community-skills:x', path: '/c/x/SKILL.md' },
+      // SKILL.md 的 name 不是目录名时,按目录名生成 ID
+      { name: 'community-skills:Pretty Name', path: '/c/pretty-name/SKILL.md' },
+    ].map(s => ({ ...s, scope: 'user' as const, description: '', enabled: true, pluginId: null }))
+    expect(skillsToDisable(all, 'all', '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/c/x/SKILL.md', '/c/pretty-name/SKILL.md'])
+    expect(skillsToDisable(all, ['community-skills:x', 'community-skills:pretty-name'], '/nohome', 'agent-skills').map(d => d.path)).toEqual(['/a/r/SKILL.md'])
+  })
+})
+
+describe('frontmatter', () => {
+  it('支持多行写法与引号', () => {
+    expect(frontmatter('---\nname: "a"\ndescription: |\n  line1\n  line2\nlicense: MIT\n---\n')).toEqual({ name: 'a', description: 'line1\nline2', license: 'MIT' })
+  })
+})

+ 1 - 0
docs/README.md

@@ -45,6 +45,7 @@ docs/
 | [0014](adr/0014-admin-claude-login.md) | 在后台完成 Claude 登录 | Accepted |
 | [0015](adr/0015-api-key-profiles.md) | API Key 只能使用指定的 Profile;权限模式由 Profile 决定 | Accepted |
 | [0016](adr/0016-official-skill-repos-per-engine.md) | 每个引擎接入各自厂商的官方技能仓库 | Accepted |
+| [0017](adr/0017-community-skills.md) | 第三方技能:skills.sh 目录 + GitHub 固定提交,两个引擎共用 | Accepted |
 
 ## 调研
 

+ 36 - 0
docs/adr/0017-community-skills.md

@@ -0,0 +1,36 @@
+# ADR-0017 第三方技能:skills.sh 目录 + GitHub 固定提交,两个引擎共用
+
+- 状态:Accepted · 日期:2026-10-23 · 补充 ADR-0016
+
+## 背景
+官方技能仓库只覆盖少量场景。[skills.sh](https://skills.sh) 是社区技能目录,收录 GitHub 仓库中的 SKILL.md 技能,提供搜索接口(`GET https://skills.sh/api/search?q=…`,返回 `source`=owner/repo、`skillId`、`installs`)。第三方技能是通用格式,两个引擎都能用。
+
+## 决策
+- **一个目录,两个引擎**:第三方技能安装在 `<dataDir>/data/community-skills`(服务可写的数据目录;在沙箱中单独开放只读),采用 Claude 插件格式(`.claude-plugin/plugin.json`,name = `community-skills`;技能在 `skills/<name>/`)。
+  - Claude:运行时按路径作为本地插件加载
+  - Codex:通过 `skills/extraRoots/set` 加入。已验证(codex 0.164):Codex 会读取上级目录的 plugin.json,技能名显示为 `community-skills:<name>`
+  - 两个引擎的技能 ID 一致:`community-skills:<name>`
+- **流程:搜索 → 预览 → 安装**
+  - 搜索走 skills.sh
+  - 预览时服务端取仓库默认分支的最新提交(GitHub API `commits/HEAD`),下载该提交的压缩包(≤ 50 MB,缓存在 `data/skill-catalogs/github/<owner>__<repo>/<sha>`,每个仓库只保留最新快照),定位技能目录,返回 SKILL.md 全文、文件列表、许可证与是否含脚本
+  - 后台必须勾选"已阅读"才能安装
+  - 安装使用**预览时的提交号**,不会装上未看过的内容;更新也是重新预览后再安装
+- **定位技能**:在仓库中(深度 ≤ 6)查找 SKILL.md。优先 frontmatter `name` 等于 skillId 的目录,其次目录名相同的目录,多个匹配时取路径最短的。安装目录名取 SKILL.md 的 `name`,这样 Claude 与 Codex 的技能名一致。
+- **许可**:只允许开源许可,其余拒绝安装。
+  - 识别顺序:技能目录中的 LICENSE 文件 → SKILL.md 的 `license` 字段(SPDX)→ 仓库根目录的 LICENSE
+  - 接受的许可:MIT、Apache、BSD、ISC、MPL、Unlicense、CC0 等
+  - 拒绝:专有许可、"governed by …"类第三方条款,以及无法识别的许可
+- **官方仓库不走这里**:`anthropics/skills`、`openai/skills` 在搜索结果中标记为不可安装,提示到各自引擎的页面安装(官方技能不交叉使用,ADR-0016)。
+- **默认不启用**:Profile 选择"全部技能"时**不包含**第三方技能,必须取消"全部"后单独勾选。
+  - Claude:`resolveSkills` 跳过 `optIn` 插件
+  - Codex:`skillsToDisable` 在 `all` 时禁用 `community-skills:*`
+- **文件安全**:
+  - 安装只复制普通文件,跳过符号链接,防止仓库中的链接指向服务器上的文件
+  - 单个技能最多 500 个文件、20 MB
+  - owner/repo 严格校验,不能以 `.` 开头
+  - 技能目录在两个引擎的沙箱中只读可见,脚本与其他命令一样受沙箱限制
+
+## 后果
+- 第三方技能未经审核,风险由管理员在预览时判断;审计日志记录来源、提交号、路径和许可证
+- 不依赖 `npx skills` CLI,也不执行仓库中的任何安装脚本
+- 部分仓库只在 README 中说明许可、没有 LICENSE 文件,这类技能无法安装(宁缺毋滥)

+ 1 - 1
docs/design/11-roadmap.md

@@ -17,7 +17,7 @@
 2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
 3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
 
-### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅;之后:第三方技能(skills.sh)与 Codex openai-curated 插件目录、④ 用量统计、③ 业务平台接入)
+### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅;之后:Codex openai-curated 插件目录、④ 用量统计、③ 业务平台接入)
 - CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
 - MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
 - Profile 管理(另存为、版本历史)

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

@@ -165,8 +165,8 @@ export interface SkillInfo {
   name: string
   description: string
   plugin: string
-  /** local 本地技能目录;plugin Claude 插件;official 从官方仓库安装(Codex);system 引擎自带 */
-  source: 'local' | 'plugin' | 'official' | 'system'
+  /** local 本地技能目录;plugin Claude 插件;official 从官方仓库安装(Codex);system 引擎自带;community 第三方技能(两个引擎共用,需在 Profile 中单独启用) */
+  source: 'local' | 'plugin' | 'official' | 'system' | 'community'
   license: LicenseKind
 }
 
@@ -234,3 +234,53 @@ export interface CodexSkillCatalogDto {
   skills: CodexCatalogSkillDto[]
   unmanaged: string[]
 }
+
+// ---- 第三方技能(skills.sh 目录,从 GitHub 按提交安装,两个引擎共用,ADR-0017)----
+
+/** 第三方技能的插件名,技能 ID 形如 community-skills:<name> */
+export const COMMUNITY_SKILL_PLUGIN = 'community-skills'
+
+export interface CommunitySearchResultDto {
+  /** GitHub 仓库 owner/repo */
+  source: string
+  skillId: string
+  name: string
+  installs: number
+  installed: boolean
+  /** 不能从这里安装的原因(如官方技能仓库应在对应引擎的页面安装) */
+  blocked: string | null
+}
+
+export interface CommunitySkillPreviewDto {
+  source: string
+  skillId: string
+  /** 安装后的技能名(取自 SKILL.md 的 name) */
+  name: string
+  description: string
+  sha: string
+  /** 技能在仓库中的路径 */
+  path: string
+  skillMd: string
+  files: { path: string; size: number }[]
+  /** 技能包含可执行脚本(.py / .sh / .js 等) */
+  hasScripts: boolean
+  license: LicenseKind
+  licenseName: string | null
+  /** 许可证来自哪里:技能目录、SKILL.md 声明或仓库根目录 */
+  licenseFrom: 'skill' | 'frontmatter' | 'repo' | null
+  installed: { sha: string } | null
+  /** 同名技能已从其他来源安装 */
+  conflict: string | null
+}
+
+export interface CommunitySkillDto {
+  id: string
+  name: string
+  description: string
+  source: string
+  skillId: string
+  path: string
+  sha: string
+  license: string | null
+  installedAt: string
+}