| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- // 技能与插件:调用 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 { 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'
- import type { SettingsService } from './settings'
- const CACHE_MS = 60_000
- const ACTION_TIMEOUT_MS = 300_000
- 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<PluginRef[]> {
- const s = await this.state()
- 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}` })) }
- })
- }
- /** 后台可选的全部技能:本地技能目录 + 已启用插件中的技能 */
- 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, declaredSkills(r.installPath, name))
- // 仓库根目录可能是开源许可,而单个技能是专有许可:任一技能受限即视为受限插件
- 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, 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() && (!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 {
- 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))
- }
- /** 插件自带 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
- }
- }
- /** 识别许可证: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\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: '开源许可' }
- 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
- }
- }
|