plugins.ts 9.9 KB

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