// 技能与插件:调用 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 = Promise.resolve() constructor(private settings: SettingsService, private audit: AuditService) {} private get cli() { return this.settings.get().providers.claude.cliPath } async state(force = false): Promise { 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(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 { 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 { 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 | 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 | 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(s: string): T | null { try { return JSON.parse(s) as T } catch { return null } }