plugins.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. // 技能与插件:调用 Claude Code 自带的 `claude plugin` 命令(以服务账号运行)安装和管理插件。
  2. // 插件文件由 Claude Code 自己下载、存放和更新,AgentServer 只读取清单并在运行时按路径加载,
  3. // 不复制、不修改插件内容(ADR-0013,满足官方技能许可证的要求)。
  4. import fs from 'node:fs'
  5. import path from 'node:path'
  6. import type {
  7. AvailablePluginDto, InstalledPluginDto, LicenseKind, MarketplaceDto, PluginAction, PluginStateDto, SkillInfo,
  8. } from '@agent-server/shared'
  9. import { badRequest } from '../lib/errors'
  10. import { frontmatter } from '../lib/skill-files'
  11. import { listSkills, LOCAL_PLUGIN_FALLBACK, type PluginRef } from '../providers/claude/skills'
  12. import { runClaudeCli } from '../providers/claude/cli'
  13. import type { AuditService } from './catalog'
  14. import type { SettingsService } from './settings'
  15. const CACHE_MS = 60_000
  16. const ACTION_TIMEOUT_MS = 300_000
  17. interface RawInstalled { id: string; version?: string; enabled?: boolean; installPath: string; installedAt?: string; lastUpdated?: string }
  18. interface RawAvailable { pluginId: string; name: string; description?: string; marketplaceName: string; version?: string }
  19. interface RawMarketplace { name: string; source: string; path?: string; url?: string; repo?: string; installLocation?: string }
  20. export class PluginService {
  21. private cache: { at: number; state: PluginStateDto } | null = null
  22. private queue: Promise<unknown> = Promise.resolve()
  23. constructor(private settings: SettingsService, private audit: AuditService) {}
  24. private get cli() {
  25. return this.settings.get().providers.claude.cliPath
  26. }
  27. async state(force = false): Promise<PluginStateDto> {
  28. if (!force && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.state
  29. const localSkills = readLocalSkills(this.settings.get().paths.skillsDir)
  30. try {
  31. const [list, mkts] = await Promise.all([
  32. runClaudeCli(this.cli, ['plugin', 'list', '--json', '--available'], { timeoutMs: 60_000 }),
  33. runClaudeCli(this.cli, ['plugin', 'marketplace', 'list', '--json'], { timeoutMs: 60_000 }),
  34. ])
  35. const parsed = parseJson<{ installed: RawInstalled[]; available: RawAvailable[] }>(list.stdout) ?? { installed: [], available: [] }
  36. const installed = parsed.installed.map(toInstalled)
  37. const installedIds = new Set(installed.map(p => p.id))
  38. const state: PluginStateDto = {
  39. cliPath: this.cli,
  40. installed,
  41. available: parsed.available.map(a => ({
  42. pluginId: a.pluginId, name: a.name, description: a.description ?? '', marketplaceName: a.marketplaceName,
  43. version: a.version ?? null, installed: installedIds.has(a.pluginId),
  44. }) satisfies AvailablePluginDto),
  45. marketplaces: (parseJson<RawMarketplace[]>(mkts.stdout) ?? []).map(m => ({
  46. name: m.name, source: m.source, location: m.repo ?? m.url ?? m.path ?? m.installLocation ?? null,
  47. }) satisfies MarketplaceDto),
  48. localSkills,
  49. error: list.code === 0 ? null : (list.stderr || list.stdout).trim().slice(-500),
  50. }
  51. this.cache = { at: Date.now(), state }
  52. return state
  53. } catch (err) {
  54. return { cliPath: this.cli, installed: [], available: [], marketplaces: [], localSkills, error: (err as Error).message }
  55. }
  56. }
  57. /** 运行时加载:已安装且已启用的插件 */
  58. async loadable(): Promise<PluginRef[]> {
  59. const s = await this.state()
  60. return s.installed.filter(p => p.enabled && fs.existsSync(p.installPath)).map(p => {
  61. const loadName = manifestName(p.installPath) ?? path.basename(p.installPath)
  62. return { name: loadName, path: p.installPath, skills: p.skills.map(k => ({ id: k.id, loadId: `${loadName}:${k.name}` })) }
  63. })
  64. }
  65. /** 后台可选的全部技能:本地技能目录 + 已启用插件中的技能 */
  66. async skills(): Promise<SkillInfo[]> {
  67. const s = await this.state()
  68. return [...s.localSkills, ...s.installed.filter(p => p.enabled).flatMap(p => p.skills)]
  69. }
  70. /** 串行执行插件命令,避免并发修改 Claude Code 的插件配置 */
  71. act(a: PluginAction): Promise<{ message: string; state: PluginStateDto }> {
  72. const run = async () => {
  73. const args = actionArgs(a)
  74. const r = await runClaudeCli(this.cli, ['plugin', ...args, '--json'], { timeoutMs: ACTION_TIMEOUT_MS })
  75. const line = r.stdout.trim().split('\n').reverse().map(l => parseJson<{ outcome?: string; message?: string }>(l)).find(Boolean)
  76. const ok = line ? line.outcome === 'ok' : r.code === 0
  77. const message = line?.message ?? (r.stderr || r.stdout).trim().slice(-500)
  78. this.audit.log({ actor: 'admin', kind: 'admin', summary: `插件操作 ${args.join(' ')}:${ok ? '成功' : '失败'}`, detail: { message } })
  79. if (!ok) throw badRequest(message || '操作失败')
  80. return { message, state: await this.state(true) }
  81. }
  82. const p = this.queue.then(run, run)
  83. this.queue = p.catch(() => {})
  84. return p
  85. }
  86. }
  87. function actionArgs(a: PluginAction): string[] {
  88. const id = /^[\w.@/:+-]+$/
  89. switch (a.action) {
  90. case 'marketplace-add':
  91. if (!/^[\w.@/:~+-]+$/.test(a.source)) throw badRequest('市场来源格式不正确')
  92. return ['marketplace', 'add', a.source]
  93. case 'marketplace-remove': case 'marketplace-update':
  94. if (!id.test(a.name)) throw badRequest('市场名称格式不正确')
  95. return ['marketplace', a.action === 'marketplace-remove' ? 'remove' : 'update', a.name]
  96. default:
  97. if (!id.test(a.plugin)) throw badRequest('插件 ID 格式不正确')
  98. return [a.action, a.plugin]
  99. }
  100. }
  101. function toInstalled(r: RawInstalled): InstalledPluginDto {
  102. const [name = r.id, marketplace = ''] = r.id.split('@')
  103. const rootLicense = detectLicense(r.installPath)
  104. const skills = readSkills(path.join(r.installPath, 'skills'), name, 'plugin', rootLicense.kind, declaredSkills(r.installPath, name))
  105. // 仓库根目录可能是开源许可,而单个技能是专有许可:任一技能受限即视为受限插件
  106. const restricted = skills.find(k => k.license === 'restricted')
  107. const license = restricted && rootLicense.kind !== 'restricted' ? RESTRICTED : rootLicense
  108. return {
  109. id: r.id, name, marketplace, version: r.version ?? null, enabled: r.enabled !== false, installPath: r.installPath,
  110. installedAt: r.installedAt ?? null, lastUpdated: r.lastUpdated ?? null, license, skills,
  111. }
  112. }
  113. function readLocalSkills(skillsDir: string): SkillInfo[] {
  114. let plugin = LOCAL_PLUGIN_FALLBACK
  115. try {
  116. plugin = (JSON.parse(fs.readFileSync(path.join(skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
  117. } catch {
  118. return []
  119. }
  120. return listSkills(skillsDir).length ? readSkills(path.join(skillsDir, 'skills'), plugin, 'local', 'open') : []
  121. }
  122. function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pluginLicense: LicenseKind, only: Set<string> | null = null): SkillInfo[] {
  123. let entries: fs.Dirent[]
  124. try {
  125. entries = fs.readdirSync(dir, { withFileTypes: true })
  126. } catch {
  127. return []
  128. }
  129. return entries.filter(e => e.isDirectory() && (!only || only.has(e.name)) && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map(e => {
  130. const fm = frontmatter(fs.readFileSync(path.join(dir, e.name, 'SKILL.md'), 'utf8'))
  131. const own = detectLicense(path.join(dir, e.name)).kind
  132. return {
  133. id: `${plugin}:${fm.name ?? e.name}`, name: fm.name ?? e.name, description: fm.description ?? '', plugin, source,
  134. license: own === 'unknown' ? pluginLicense : own,
  135. }
  136. }).sort((a, b) => a.name.localeCompare(b.name))
  137. }
  138. /** 插件自带 plugin.json 时的名称(按路径加载时 CLI 用它作为技能前缀) */
  139. function manifestName(installPath: string): string | null {
  140. try {
  141. return (JSON.parse(fs.readFileSync(path.join(installPath, '.claude-plugin/plugin.json'), 'utf8')) as { name?: string }).name ?? null
  142. } catch {
  143. return null
  144. }
  145. }
  146. /**
  147. * 市场清单中为该插件声明的技能子集。例如官方仓库的 document-skills 与 example-skills 共用同一个目录,
  148. * 分别只声明了部分技能(strict: false)。没有声明时返回 null,表示 skills/ 下全部技能。
  149. */
  150. function declaredSkills(installPath: string, plugin: string): Set<string> | null {
  151. try {
  152. const m = JSON.parse(fs.readFileSync(path.join(installPath, '.claude-plugin/marketplace.json'), 'utf8')) as { plugins?: { name: string; skills?: string[] }[] }
  153. const entry = m.plugins?.find(p => p.name === plugin)
  154. return entry?.skills?.length ? new Set(entry.skills.map(s => path.basename(s))) : null
  155. } catch {
  156. return null
  157. }
  158. }
  159. /** 识别许可证:Anthropic 专有许可(源码可见、仅限在其服务内使用)或常见开源许可 */
  160. function detectLicense(dir: string): { kind: LicenseKind; summary: string | null } {
  161. let text = ''
  162. try {
  163. const f = fs.readdirSync(dir).find(n => /^licen[cs]e/i.test(n))
  164. if (f) text = fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4000)
  165. } catch {
  166. return { kind: 'unknown', summary: null }
  167. }
  168. if (!text) return { kind: 'unknown', summary: null }
  169. if (/agreement\s+with\s+Anthropic/i.test(text) || /All rights reserved/i.test(text)) {
  170. return RESTRICTED
  171. }
  172. if (/Apache License|MIT License|BSD|Mozilla Public License|ISC License/i.test(text)) return { kind: 'open', summary: '开源许可' }
  173. return { kind: 'unknown', summary: null }
  174. }
  175. const RESTRICTED = {
  176. kind: 'restricted' as const,
  177. summary: 'Anthropic 专有许可:只能在 Anthropic 的服务(Claude Code)中使用;不得复制到服务之外、修改或分发',
  178. }
  179. function parseJson<T>(s: string): T | null {
  180. try {
  181. return JSON.parse(s) as T
  182. } catch {
  183. return null
  184. }
  185. }