Ver Fonte

fix: 修复已安装的官方技能(document-skills)未被加载

问题:插件按路径加载时,没有 plugin.json 的插件以目录名为技能前缀
(如 dbd4588f9e10:xlsx),与传入的 document-skills:xlsx 对不上,技能全部被
过滤掉;同时 CLI 会加载目录下全部 19 个技能,而 document-skills 只声明了 4 个

- 读取 marketplace.json 中为插件声明的技能子集,只展示和启用这些技能
  (document-skills:xlsx、docx、pptx、pdf)
- 区分展示 ID 与加载 ID:后台和 Profile 使用 document-skills:xlsx,
  运行时转换为 CLI 使用的 ID,时间线显示时再换回展示 ID
- 许可证识别兼容跨行文本(agreement with\nAnthropic)
- 新增测试:模拟官方仓库布局(多个插件共用目录、没有 plugin.json、
  只声明部分技能);展示 ID 到加载 ID 的映射
- 文档:03-providers 实现记录

验收:用真实任务验证官方 docx 技能被加载,在沙箱中用 docx-js 生成 Word;
公式重算和格式转换分别走 recalc_workbook 和 convert_document

测试:41 个通过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang há 1 semana atrás
pai
commit
932f64970f

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

@@ -239,8 +239,11 @@ interface OpenBlock { kind: 'text' | 'thinking'; id: string; text: string }
 /** 把 SDK 消息流映射为统一事件;收到 result 时返回 RunResult */
 class MessageMapper {
   private blocks = new Map<number, OpenBlock>()
+  private skillNames: Map<string, string>
 
-  constructor(private ctx: RunContext, private deniedToolIds: Set<string>) {}
+  constructor(private ctx: RunContext, private deniedToolIds: Set<string>) {
+    this.skillNames = new Map(ctx.plugins.flatMap(p => p.skills.map(k => [k.loadId, k.id] as const)))
+  }
 
   handle(m: SDKMessage): RunResult | null {
     switch (m.type) {
@@ -256,7 +259,10 @@ class MessageMapper {
         for (const block of m.message.content) {
           if (block.type === 'tool_use') {
             const input = (block.input ?? {}) as Record<string, unknown>
-            this.ctx.emit('tool.started', { toolCallId: block.id, name: block.name, title: toolTitle(block.name, input, this.ctx.workspaceDir), input })
+            let title = toolTitle(block.name, input, this.ctx.workspaceDir)
+            // 插件技能按路径加载时 CLI 用目录名作前缀,显示时换回后台中的技能 ID
+            if (block.name === 'Skill') title = this.skillNames.get(title) ?? title
+            this.ctx.emit('tool.started', { toolCallId: block.id, name: block.name, title, input })
           }
         }
         return null

+ 12 - 7
apps/server/src/providers/claude/skills.ts

@@ -5,7 +5,11 @@ import path from 'node:path'
 /** 本地技能目录的默认插件名(读取 plugin.json 失败时使用) */
 export const LOCAL_PLUGIN_FALLBACK = 'agent-skills'
 
-export interface PluginRef { name: string; path: string; skills: string[] }
+/**
+ * 运行时加载的插件。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 function listSkills(dir: string): string[] {
   try {
@@ -22,7 +26,7 @@ export function listSkills(dir: string): string[] {
 export function localPlugin(skillsDir: string): PluginRef | null {
   try {
     const name = (JSON.parse(fs.readFileSync(path.join(skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
-    return { name, path: skillsDir, skills: listSkills(skillsDir).map(s => `${name}:${s}`) }
+    return { name, path: skillsDir, skills: listSkills(skillsDir).map(s => ({ id: `${name}:${s}`, loadId: `${name}:${s}` })) }
   } catch {
     return null
   }
@@ -35,10 +39,11 @@ export function localPlugin(skillsDir: string): PluginRef | null {
 export function resolveSkills(selected: string[] | 'all', plugins: PluginRef[]) {
   const available = plugins.flatMap(p => p.skills)
   const local = plugins[0]
-  const wanted = selected === 'all'
-    ? available
-    : selected.map(s => (s.includes(':') || !local ? s : `${local.name}:${s}`)).filter(s => available.includes(s))
+  const wantedIds = selected === 'all'
+    ? new Set(available.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))
   // 只加载至少有一个技能被选中的插件
-  const used = plugins.filter(p => p.skills.some(s => wanted.includes(s)))
-  return { plugins: used.map(p => ({ type: 'local' as const, path: p.path })), skills: wanted }
+  const used = plugins.filter(p => p.skills.some(s => wantedIds.has(s.id)))
+  return { plugins: used.map(p => ({ type: 'local' as const, path: p.path })), skills: chosen.map(s => s.loadId) }
 }

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

@@ -7,7 +7,7 @@ import type {
   AvailablePluginDto, InstalledPluginDto, LicenseKind, MarketplaceDto, PluginAction, PluginStateDto, SkillInfo,
 } from '@agent-server/shared'
 import { badRequest } from '../lib/errors'
-import { listSkills, LOCAL_PLUGIN_FALLBACK } from '../providers/claude/skills'
+import { listSkills, LOCAL_PLUGIN_FALLBACK, type PluginRef } from '../providers/claude/skills'
 import { runClaudeCli } from '../providers/claude/cli'
 import type { AuditService } from './catalog'
 import type { SettingsService } from './settings'
@@ -15,7 +15,6 @@ import type { SettingsService } from './settings'
 const CACHE_MS = 60_000
 const ACTION_TIMEOUT_MS = 300_000
 
-export interface LoadablePlugin { name: string; path: string; skills: string[]; license: LicenseKind }
 
 interface RawInstalled { id: string; version?: string; enabled?: boolean; installPath: string; installedAt?: string; lastUpdated?: string }
 interface RawAvailable { pluginId: string; name: string; description?: string; marketplaceName: string; version?: string }
@@ -63,10 +62,12 @@ export class PluginService {
   }
 
   /** 运行时加载:已安装且已启用的插件 */
-  async loadable(): Promise<LoadablePlugin[]> {
+  async loadable(): Promise<PluginRef[]> {
     const s = await this.state()
-    return s.installed.filter(p => p.enabled && fs.existsSync(p.installPath))
-      .map(p => ({ name: p.name, path: p.installPath, skills: p.skills.map(k => k.id), license: p.license.kind }))
+    return s.installed.filter(p => p.enabled && fs.existsSync(p.installPath)).map(p => {
+      const loadName = manifestName(p.installPath) ?? path.basename(p.installPath)
+      return { name: loadName, path: p.installPath, skills: p.skills.map(k => ({ id: k.id, loadId: `${loadName}:${k.name}` })) }
+    })
   }
 
   /** 后台可选的全部技能:本地技能目录 + 已启用插件中的技能 */
@@ -111,7 +112,7 @@ function actionArgs(a: PluginAction): string[] {
 function toInstalled(r: RawInstalled): InstalledPluginDto {
   const [name = r.id, marketplace = ''] = r.id.split('@')
   const rootLicense = detectLicense(r.installPath)
-  const skills = readSkills(path.join(r.installPath, 'skills'), name, 'plugin', rootLicense.kind)
+  const skills = readSkills(path.join(r.installPath, 'skills'), name, 'plugin', rootLicense.kind, declaredSkills(r.installPath, name))
   // 仓库根目录可能是开源许可,而单个技能是专有许可:任一技能受限即视为受限插件
   const restricted = skills.find(k => k.license === 'restricted')
   const license = restricted && rootLicense.kind !== 'restricted' ? RESTRICTED : rootLicense
@@ -131,14 +132,14 @@ function readLocalSkills(skillsDir: string): SkillInfo[] {
   return listSkills(skillsDir).length ? readSkills(path.join(skillsDir, 'skills'), plugin, 'local', 'open') : []
 }
 
-function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pluginLicense: LicenseKind): SkillInfo[] {
+function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pluginLicense: LicenseKind, only: Set<string> | null = null): SkillInfo[] {
   let entries: fs.Dirent[]
   try {
     entries = fs.readdirSync(dir, { withFileTypes: true })
   } catch {
     return []
   }
-  return entries.filter(e => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map(e => {
+  return entries.filter(e => e.isDirectory() && (!only || only.has(e.name)) && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map(e => {
     const fm = frontmatter(fs.readFileSync(path.join(dir, e.name, 'SKILL.md'), 'utf8'))
     const own = detectLicense(path.join(dir, e.name)).kind
     return {
@@ -148,6 +149,29 @@ function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pl
   }).sort((a, b) => a.name.localeCompare(b.name))
 }
 
+/** 插件自带 plugin.json 时的名称(按路径加载时 CLI 用它作为技能前缀) */
+function manifestName(installPath: string): string | null {
+  try {
+    return (JSON.parse(fs.readFileSync(path.join(installPath, '.claude-plugin/plugin.json'), 'utf8')) as { name?: string }).name ?? null
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 市场清单中为该插件声明的技能子集。例如官方仓库的 document-skills 与 example-skills 共用同一个目录,
+ * 分别只声明了部分技能(strict: false)。没有声明时返回 null,表示 skills/ 下全部技能。
+ */
+function declaredSkills(installPath: string, plugin: string): Set<string> | null {
+  try {
+    const m = JSON.parse(fs.readFileSync(path.join(installPath, '.claude-plugin/marketplace.json'), 'utf8')) as { plugins?: { name: string; skills?: string[] }[] }
+    const entry = m.plugins?.find(p => p.name === plugin)
+    return entry?.skills?.length ? new Set(entry.skills.map(s => path.basename(s))) : null
+  } catch {
+    return null
+  }
+}
+
 function frontmatter(md: string): Record<string, string> {
   const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
   const out: Record<string, string> = {}
@@ -168,7 +192,7 @@ function detectLicense(dir: string): { kind: LicenseKind; summary: string | null
     return { kind: 'unknown', summary: null }
   }
   if (!text) return { kind: 'unknown', summary: null }
-  if (/agreement with Anthropic/i.test(text) || /All rights reserved/i.test(text)) {
+  if (/agreement\s+with\s+Anthropic/i.test(text) || /All rights reserved/i.test(text)) {
     return RESTRICTED
   }
   if (/Apache License|MIT License|BSD|Mozilla Public License|ISC License/i.test(text)) return { kind: 'open', summary: '开源许可' }

+ 34 - 9
apps/server/test/plugins.test.ts

@@ -10,18 +10,18 @@ import { createDb, migrateDb } from '../src/db'
 
 describe('resolveSkills', () => {
   const plugins = [
-    { name: 'agent-skills', path: '/a', skills: ['agent-skills:report-conventions'] },
-    { name: 'document-skills', path: '/b', skills: ['document-skills:xlsx', 'document-skills:docx'] },
+    { name: 'agent-skills', path: '/a', skills: [{ id: 'agent-skills:report-conventions', loadId: 'agent-skills:report-conventions' }] },
+    // 官方仓库按路径加载时,CLI 以目录名作为前缀
+    { name: 'dbd4588f9e10', path: '/b', skills: [{ id: 'document-skills:xlsx', loadId: 'dbd4588f9e10:xlsx' }, { id: 'document-skills:docx', loadId: 'dbd4588f9e10:docx' }] },
   ]
-  it('all 选中全部技能并加载全部插件', () => {
+  it('all 选中全部技能并加载全部插件,输出 CLI 使用的 ID', () => {
     const r = resolveSkills('all', plugins)
-    expect(r.skills).toHaveLength(3)
+    expect(r.skills).toEqual(['agent-skills:report-conventions', 'dbd4588f9e10:xlsx', 'dbd4588f9e10:docx'])
     expect(r.plugins.map(p => p.path)).toEqual(['/a', '/b'])
   })
-  it('不带前缀的名称指本地技能;只加载被用到的插件', () => {
-    const r = resolveSkills(['report-conventions'], plugins)
-    expect(r.skills).toEqual(['agent-skills:report-conventions'])
-    expect(r.plugins.map(p => p.path)).toEqual(['/a'])
+  it('按展示 ID 选择;不带前缀的名称指本地技能;只加载被用到的插件', () => {
+    expect(resolveSkills(['report-conventions'], plugins)).toEqual({ plugins: [{ type: 'local', path: '/a' }], skills: ['agent-skills:report-conventions'] })
+    expect(resolveSkills(['document-skills:xlsx'], plugins)).toEqual({ plugins: [{ type: 'local', path: '/b' }], skills: ['dbd4588f9e10:xlsx'] })
   })
   it('忽略不存在的技能;空列表不加载任何技能', () => {
     expect(resolveSkills(['document-skills:pptx'], plugins).skills).toEqual([])
@@ -43,7 +43,7 @@ describe('PluginService(真实 CLI)', () => {
     fs.writeFileSync(path.join(mkt, '.claude-plugin/marketplace.json'), JSON.stringify({ name: 'test-mkt', owner: { name: 't' }, plugins: [{ name: 'demo', source: './demo', description: '测试插件' }] }))
     fs.writeFileSync(path.join(mkt, 'demo/.claude-plugin/plugin.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }))
     fs.writeFileSync(path.join(mkt, 'demo/skills/hello/SKILL.md'), '---\nname: hello\ndescription: "打个招呼"\n---\nhi\n')
-    fs.writeFileSync(path.join(mkt, 'demo/skills/hello/LICENSE.txt'), '© 2025 Anthropic, PBC. All rights reserved.\nUse is governed by your agreement with Anthropic.')
+    fs.writeFileSync(path.join(mkt, 'demo/skills/hello/LICENSE.txt'), 'LICENSE: Use is governed by your agreement with\nAnthropic regarding use of the services.')
     const db = createDb(path.join(tmp, 'db.sqlite'))
     migrateDb(db)
     svc = new PluginService(new SettingsService(db), new AuditService(db))
@@ -57,6 +57,7 @@ describe('PluginService(真实 CLI)', () => {
     expect(p.license.kind).toBe('restricted')
     expect(p.skills).toEqual([expect.objectContaining({ id: 'demo:hello', description: '打个招呼', license: 'restricted' })])
     expect((await svc.loadable()).map(l => l.name)).toEqual(['demo'])
+    expect((await svc.loadable())[0]!.skills).toEqual([{ id: 'demo:hello', loadId: 'demo:hello' }])
 
     s = (await svc.act({ action: 'disable', plugin: 'demo@test-mkt' })).state
     expect(s.installed.find(x => x.id === 'demo@test-mkt')!.enabled).toBe(false)
@@ -66,6 +67,30 @@ describe('PluginService(真实 CLI)', () => {
     expect(s.installed).toEqual([])
   }, 120_000)
 
+  it('官方仓库式布局:多个插件共用目录、没有 plugin.json、只声明部分技能', async () => {
+    const repo = path.join(tmp, 'repo')
+    for (const k of ['xlsx', 'docx', 'art']) {
+      fs.mkdirSync(path.join(repo, 'skills', k), { recursive: true })
+      fs.writeFileSync(path.join(repo, 'skills', k, 'SKILL.md'), `---\nname: ${k}\ndescription: ${k}\n---\n`)
+    }
+    fs.mkdirSync(path.join(repo, '.claude-plugin'), { recursive: true })
+    fs.writeFileSync(path.join(repo, '.claude-plugin/marketplace.json'), JSON.stringify({
+      name: 'repo-mkt', owner: { name: 't' },
+      plugins: [
+        { name: 'doc-skills', source: './', strict: false, skills: ['./skills/xlsx', './skills/docx'] },
+        { name: 'example', source: './', strict: false, skills: ['./skills/art'] },
+      ],
+    }))
+    await svc.act({ action: 'marketplace-add', source: repo })
+    const s = (await svc.act({ action: 'install', plugin: 'doc-skills@repo-mkt' })).state
+    const p = s.installed.find(x => x.id === 'doc-skills@repo-mkt')!
+    expect(p.skills.map(k => k.id)).toEqual(['doc-skills:docx', 'doc-skills:xlsx'])
+    const l = (await svc.loadable()).find(x => x.path === p.installPath)!
+    expect(l.name).toBe(path.basename(p.installPath))
+    expect(l.skills.map(k => k.loadId)).toEqual([`${l.name}:docx`, `${l.name}:xlsx`])
+    await svc.act({ action: 'uninstall', plugin: 'doc-skills@repo-mkt' })
+  }, 120_000)
+
   it('失败的操作返回错误信息', async () => {
     await expect(svc.act({ action: 'install', plugin: 'nope@nowhere' })).rejects.toThrow(/not found/)
   }, 60_000)

+ 3 - 0
docs/design/03-providers.md

@@ -145,4 +145,7 @@ Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使
 | 单条消息的流式输入在输出 `result` 后正常结束 | 每个 Run 调用一次 `query()` 并用 `resume` 续接,无需常驻进程 |
 | 登录态 | `system/init` 中 `apiKeySource: none` 表示走 claude.ai OAuth;服务环境中删除 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` |
 
+| 按路径加载插件(`plugins: [{ type: 'local', path }]`)时,没有 `.claude-plugin/plugin.json` 的插件以**目录名**作为技能前缀,并加载 `skills/` 下的**全部**技能(官方仓库的 document-skills 实际是 `dbd4588f9e10:xlsx` 等,并会带上属于 example-skills 的 15 个技能) | 读取市场清单 `marketplace.json` 中为该插件声明的技能子集,只展示和启用这些;后台与 Profile 使用友好 ID(`document-skills:xlsx`),运行时翻译为 CLI 的 ID,时间线显示时再换回来(2026-10-23) |
+| `skills: 'all'` 会包含 CLI 自带的捆绑技能(deep-research、dataviz、code-review 等) | 始终传显式列表,只包含本地技能目录和已启用插件声明的技能 |
+
 服务端提供的进程内 MCP 工具:`mcp__office__convert_document`(LibreOffice 文档转换,见 06 §8)。M2 起会迁移到 MCP Gateway,供 Codex 共用。