|
|
@@ -0,0 +1,189 @@
|
|
|
+// 技能与插件:调用 Claude Code 自带的 `claude plugin` 命令(以服务账号运行)安装和管理插件。
|
|
|
+// 插件文件由 Claude Code 自己下载、存放和更新,AgentServer 只读取清单并在运行时按路径加载,
|
|
|
+// 不复制、不修改插件内容(ADR-0013,满足官方技能许可证的要求)。
|
|
|
+import fs from 'node:fs'
|
|
|
+import path from 'node:path'
|
|
|
+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 { runClaudeCli } from '../providers/claude/cli'
|
|
|
+import type { AuditService } from './catalog'
|
|
|
+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 }
|
|
|
+interface RawMarketplace { name: string; source: string; path?: string; url?: string; repo?: string; installLocation?: string }
|
|
|
+
|
|
|
+export class PluginService {
|
|
|
+ private cache: { at: number; state: PluginStateDto } | null = null
|
|
|
+ private queue: Promise<unknown> = Promise.resolve()
|
|
|
+
|
|
|
+ constructor(private settings: SettingsService, private audit: AuditService) {}
|
|
|
+
|
|
|
+ private get cli() {
|
|
|
+ return this.settings.get().providers.claude.cliPath
|
|
|
+ }
|
|
|
+
|
|
|
+ async state(force = false): Promise<PluginStateDto> {
|
|
|
+ if (!force && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.state
|
|
|
+ const localSkills = readLocalSkills(this.settings.get().paths.skillsDir)
|
|
|
+ try {
|
|
|
+ const [list, mkts] = await Promise.all([
|
|
|
+ runClaudeCli(this.cli, ['plugin', 'list', '--json', '--available'], { timeoutMs: 60_000 }),
|
|
|
+ runClaudeCli(this.cli, ['plugin', 'marketplace', 'list', '--json'], { timeoutMs: 60_000 }),
|
|
|
+ ])
|
|
|
+ const parsed = parseJson<{ installed: RawInstalled[]; available: RawAvailable[] }>(list.stdout) ?? { installed: [], available: [] }
|
|
|
+ const installed = parsed.installed.map(toInstalled)
|
|
|
+ const installedIds = new Set(installed.map(p => p.id))
|
|
|
+ const state: PluginStateDto = {
|
|
|
+ cliPath: this.cli,
|
|
|
+ installed,
|
|
|
+ available: parsed.available.map(a => ({
|
|
|
+ pluginId: a.pluginId, name: a.name, description: a.description ?? '', marketplaceName: a.marketplaceName,
|
|
|
+ version: a.version ?? null, installed: installedIds.has(a.pluginId),
|
|
|
+ }) satisfies AvailablePluginDto),
|
|
|
+ marketplaces: (parseJson<RawMarketplace[]>(mkts.stdout) ?? []).map(m => ({
|
|
|
+ name: m.name, source: m.source, location: m.repo ?? m.url ?? m.path ?? m.installLocation ?? null,
|
|
|
+ }) satisfies MarketplaceDto),
|
|
|
+ localSkills,
|
|
|
+ error: list.code === 0 ? null : (list.stderr || list.stdout).trim().slice(-500),
|
|
|
+ }
|
|
|
+ this.cache = { at: Date.now(), state }
|
|
|
+ return state
|
|
|
+ } catch (err) {
|
|
|
+ return { cliPath: this.cli, installed: [], available: [], marketplaces: [], localSkills, error: (err as Error).message }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 运行时加载:已安装且已启用的插件 */
|
|
|
+ async loadable(): Promise<LoadablePlugin[]> {
|
|
|
+ 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 }))
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 后台可选的全部技能:本地技能目录 + 已启用插件中的技能 */
|
|
|
+ async skills(): Promise<SkillInfo[]> {
|
|
|
+ const s = await this.state()
|
|
|
+ return [...s.localSkills, ...s.installed.filter(p => p.enabled).flatMap(p => p.skills)]
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 串行执行插件命令,避免并发修改 Claude Code 的插件配置 */
|
|
|
+ act(a: PluginAction): Promise<{ message: string; state: PluginStateDto }> {
|
|
|
+ const run = async () => {
|
|
|
+ const args = actionArgs(a)
|
|
|
+ const r = await runClaudeCli(this.cli, ['plugin', ...args, '--json'], { timeoutMs: ACTION_TIMEOUT_MS })
|
|
|
+ const line = r.stdout.trim().split('\n').reverse().map(l => parseJson<{ outcome?: string; message?: string }>(l)).find(Boolean)
|
|
|
+ const ok = line ? line.outcome === 'ok' : r.code === 0
|
|
|
+ const message = line?.message ?? (r.stderr || r.stdout).trim().slice(-500)
|
|
|
+ this.audit.log({ actor: 'admin', kind: 'admin', summary: `插件操作 ${args.join(' ')}:${ok ? '成功' : '失败'}`, detail: { message } })
|
|
|
+ if (!ok) throw badRequest(message || '操作失败')
|
|
|
+ return { message, state: await this.state(true) }
|
|
|
+ }
|
|
|
+ const p = this.queue.then(run, run)
|
|
|
+ this.queue = p.catch(() => {})
|
|
|
+ return p
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function actionArgs(a: PluginAction): string[] {
|
|
|
+ const id = /^[\w.@/:+-]+$/
|
|
|
+ switch (a.action) {
|
|
|
+ case 'marketplace-add':
|
|
|
+ if (!/^[\w.@/:~+-]+$/.test(a.source)) throw badRequest('市场来源格式不正确')
|
|
|
+ return ['marketplace', 'add', a.source]
|
|
|
+ case 'marketplace-remove': case 'marketplace-update':
|
|
|
+ if (!id.test(a.name)) throw badRequest('市场名称格式不正确')
|
|
|
+ return ['marketplace', a.action === 'marketplace-remove' ? 'remove' : 'update', a.name]
|
|
|
+ default:
|
|
|
+ if (!id.test(a.plugin)) throw badRequest('插件 ID 格式不正确')
|
|
|
+ return [a.action, a.plugin]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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 restricted = skills.find(k => k.license === 'restricted')
|
|
|
+ const license = restricted && rootLicense.kind !== 'restricted' ? RESTRICTED : rootLicense
|
|
|
+ return {
|
|
|
+ id: r.id, name, marketplace, version: r.version ?? null, enabled: r.enabled !== false, installPath: r.installPath,
|
|
|
+ installedAt: r.installedAt ?? null, lastUpdated: r.lastUpdated ?? null, license, skills,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function readLocalSkills(skillsDir: string): SkillInfo[] {
|
|
|
+ let plugin = LOCAL_PLUGIN_FALLBACK
|
|
|
+ try {
|
|
|
+ plugin = (JSON.parse(fs.readFileSync(path.join(skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
|
|
|
+ } catch {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return listSkills(skillsDir).length ? readSkills(path.join(skillsDir, 'skills'), plugin, 'local', 'open') : []
|
|
|
+}
|
|
|
+
|
|
|
+function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pluginLicense: LicenseKind): 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 => {
|
|
|
+ const fm = frontmatter(fs.readFileSync(path.join(dir, e.name, 'SKILL.md'), 'utf8'))
|
|
|
+ const own = detectLicense(path.join(dir, e.name)).kind
|
|
|
+ return {
|
|
|
+ id: `${plugin}:${fm.name ?? e.name}`, name: fm.name ?? e.name, description: fm.description ?? '', plugin, source,
|
|
|
+ license: own === 'unknown' ? pluginLicense : own,
|
|
|
+ }
|
|
|
+ }).sort((a, b) => a.name.localeCompare(b.name))
|
|
|
+}
|
|
|
+
|
|
|
+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 } {
|
|
|
+ let text = ''
|
|
|
+ try {
|
|
|
+ const f = fs.readdirSync(dir).find(n => /^licen[cs]e/i.test(n))
|
|
|
+ if (f) text = fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4000)
|
|
|
+ } catch {
|
|
|
+ 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)) {
|
|
|
+ return RESTRICTED
|
|
|
+ }
|
|
|
+ if (/Apache License|MIT License|BSD|Mozilla Public License|ISC License/i.test(text)) return { kind: 'open', summary: '开源许可' }
|
|
|
+ return { kind: 'unknown', summary: null }
|
|
|
+}
|
|
|
+
|
|
|
+const RESTRICTED = {
|
|
|
+ kind: 'restricted' as const,
|
|
|
+ summary: 'Anthropic 专有许可:只能在 Anthropic 的服务(Claude Code)中使用;不得复制到服务之外、修改或分发',
|
|
|
+}
|
|
|
+
|
|
|
+function parseJson<T>(s: string): T | null {
|
|
|
+ try {
|
|
|
+ return JSON.parse(s) as T
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+}
|