cc-haha/src/server/services/pluginService.ts
程序员阿江(Relakkes) 333271b159 fix: make portable plugin skills visible after CLI installs
Portable desktop installs can mutate CLAUDE_CONFIG_DIR from an embedded or external CLI while the desktop server is already running. The server skill surface now rereads plugin-related state before listing skills, and /reload-plugins refreshes plugin skills alongside plugin commands so terminal sessions see the same enabled skills without a restart.

Constraint: Portable mode expects CLI, desktop API, and terminal sessions to share the selected CLAUDE_CONFIG_DIR even when writes happen from separate processes
Rejected: Require a desktop restart after plugin install | leaves the portable install workflow stale and contradicts the existing /reload-plugins handoff
Confidence: high
Scope-risk: moderate
Directive: Keep skill discovery and reload summaries counting plugin skills separately from plugin slash commands
Tested: bun test src/server/__tests__/skills.test.ts
Tested: bun test src/server/__tests__/plugins.test.ts
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun run check:server
Tested: CLAUDE_CONFIG_DIR=/tmp portable smoke with anthropics/skills, two cwd /api/skills checks, and DeepSeek skill slash-command calls
Tested: bun run quality:gate --mode baseline --allow-live --provider-model deepseek:main:deepseek-main --only provider-smoke:*
Not-tested: Packaged desktop UI visual smoke
2026-05-25 17:32:15 +08:00

819 lines
25 KiB
TypeScript

import { basename, join, sep } from 'node:path'
import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'
import type { McpServerConfig } from '../../services/mcp/types.js'
import {
disablePluginOp,
enablePluginOp,
type InstallableScope,
uninstallPluginOp,
updatePluginOp,
} from '../../services/plugins/pluginOperations.js'
import { getAgentDefinitionsWithOverrides } from '../../tools/AgentTool/loadAgentsDir.js'
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
import { getPluginErrorMessage } from '../../types/plugin.js'
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
import {
getMarketplaceSourceDisplay,
} from '../../utils/plugins/marketplaceHelpers.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
import {
loadKnownMarketplacesConfig,
} from '../../utils/plugins/marketplaceManager.js'
import { loadPluginLspServers } from '../../utils/plugins/lspPluginIntegration.js'
import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
import { loadPluginHooks } from '../../utils/plugins/loadPluginHooks.js'
import { getPluginSkills } from '../../utils/plugins/loadPluginCommands.js'
import { clearPluginCacheExclusions } from '../../utils/plugins/orphanedPluginFilter.js'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { extractDescriptionFromMarkdown } from '../../utils/markdownConfigLoader.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import type {
PluginInstallationEntry,
PluginScope,
} from '../../utils/plugins/schemas.js'
import { ApiError } from '../middleware/errorHandler.js'
import { walkPluginMarkdown } from '../../utils/plugins/walkPluginMarkdown.js'
import type { HookCommand, HooksSettings } from '../../utils/settings/types.js'
export type ApiPluginCapabilitySet = {
commands: string[]
agents: string[]
skills: string[]
hooks: string[]
mcpServers: string[]
lspServers: string[]
}
export type ApiPluginSummary = {
id: string
name: string
marketplace: string
scope: PluginScope | 'builtin'
enabled: boolean
hasErrors: boolean
isBuiltin: boolean
version?: string
description?: string
authorName?: string
installPath?: string
projectPath?: string
componentCounts: Record<keyof ApiPluginCapabilitySet, number>
errors: string[]
}
export type ApiPluginSkillEntry = {
name: string
displayName?: string
description: string
version?: string
pluginName?: string
}
export type ApiPluginCommandEntry = {
name: string
description: string
}
export type ApiPluginAgentEntry = {
name: string
displayName?: string
description: string
}
export type ApiPluginHookEntry = {
event: string
matcher?: string
actions: string[]
}
export type ApiPluginMcpServerEntry = {
name: string
displayName?: string
transport: string
summary: string
}
export type ApiPluginDetail = ApiPluginSummary & {
capabilities: ApiPluginCapabilitySet
commandEntries: ApiPluginCommandEntry[]
agentEntries: ApiPluginAgentEntry[]
hookEntries: ApiPluginHookEntry[]
skillEntries: ApiPluginSkillEntry[]
mcpServerEntries: ApiPluginMcpServerEntry[]
}
export type ApiPluginMarketplaceSummary = {
name: string
source: string
lastUpdated?: string
autoUpdate: boolean
installedCount: number
}
export type ApiPluginListResponse = {
plugins: ApiPluginSummary[]
marketplaces: ApiPluginMarketplaceSummary[]
summary: {
total: number
enabled: number
errorCount: number
marketplaceCount: number
}
}
export type ApiPluginActionResponse = {
ok: true
message: string
}
export type ApiPluginReloadResponse = {
ok: true
summary: {
enabled: number
disabled: number
skills: number
agents: number
hooks: number
mcpServers: number
lspServers: number
errors: number
}
}
type HydratedPluginState = {
enabled: LoadedPlugin[]
disabled: LoadedPlugin[]
errors: PluginError[]
}
export class PluginService {
async listPlugins(cwd?: string): Promise<ApiPluginListResponse> {
const { plugins, marketplaces } = await this.collectPluginState(cwd)
return {
plugins,
marketplaces,
summary: {
total: plugins.length,
enabled: plugins.filter((plugin) => plugin.enabled).length,
errorCount: plugins.reduce((sum, plugin) => sum + plugin.errors.length, 0),
marketplaceCount: marketplaces.length,
},
}
}
async getPluginDetail(
pluginId: string,
cwd?: string,
): Promise<ApiPluginDetail> {
const { plugins, detailById } = await this.collectPluginState(cwd)
const detail = detailById.get(pluginId)
if (!detail) {
throw ApiError.notFound(`Plugin not found: ${pluginId}`)
}
return detail
}
async enablePlugin(
pluginId: string,
scope?: InstallableScope,
): Promise<ApiPluginActionResponse> {
const result = await enablePluginOp(pluginId, scope)
if (!result.success) {
throw ApiError.badRequest(result.message)
}
return { ok: true, message: result.message }
}
async disablePlugin(
pluginId: string,
scope?: InstallableScope,
): Promise<ApiPluginActionResponse> {
const result = await disablePluginOp(pluginId, scope)
if (!result.success) {
throw ApiError.badRequest(result.message)
}
return { ok: true, message: result.message }
}
async uninstallPlugin(
pluginId: string,
scope?: InstallableScope,
keepData = false,
): Promise<ApiPluginActionResponse> {
if (!scope) {
throw ApiError.badRequest('Plugin uninstall requires a scope')
}
const result = await uninstallPluginOp(pluginId, scope, keepData)
if (!result.success) {
throw ApiError.badRequest(result.message)
}
return { ok: true, message: result.message }
}
async updatePlugin(
pluginId: string,
scope?: PluginScope,
): Promise<ApiPluginActionResponse> {
if (!scope) {
throw ApiError.badRequest('Plugin update requires a scope')
}
const result = await updatePluginOp(pluginId, scope)
if (!result.success) {
throw ApiError.badRequest(result.message)
}
return { ok: true, message: result.message }
}
async reloadPlugins(cwd?: string): Promise<ApiPluginReloadResponse> {
resetSettingsCache()
clearAllCaches()
clearPluginCacheExclusions()
const pluginState = await this.loadPluginState()
const { enabled, disabled, errors } = pluginState
const [skills, agentDefinitions] = await Promise.all([
getPluginSkills(),
getAgentDefinitionsWithOverrides(cwd),
])
const hookCount = await this.getHookCount(enabled)
const mcpCounts = await Promise.all(
enabled.map(async (plugin) => {
const servers = plugin.mcpServers || await loadPluginMcpServers(plugin, errors)
return servers ? Object.keys(servers).length : 0
}),
)
const lspCounts = await Promise.all(
enabled.map(async (plugin) => {
const servers = plugin.lspServers || await loadPluginLspServers(plugin, errors)
return servers ? Object.keys(servers).length : 0
}),
)
return {
ok: true,
summary: {
enabled: enabled.length,
disabled: disabled.length,
skills: skills.length,
agents: agentDefinitions.allAgents.length,
hooks: hookCount,
mcpServers: mcpCounts.reduce((sum, count) => sum + count, 0),
lspServers: lspCounts.reduce((sum, count) => sum + count, 0),
errors: errors.length,
},
}
}
private async collectPluginState(cwd?: string): Promise<{
plugins: ApiPluginSummary[]
detailById: Map<string, ApiPluginDetail>
marketplaces: ApiPluginMarketplaceSummary[]
}> {
const [pluginState, installedData, marketplaceConfig] = await Promise.all([
this.loadPluginState(),
Promise.resolve(loadInstalledPluginsV2()),
loadKnownMarketplacesConfig(),
])
const allLoaded = [...pluginState.enabled, ...pluginState.disabled]
const loadedById = new Map(
allLoaded
.filter((plugin) => !plugin.source.endsWith('@inline'))
.map((plugin) => [plugin.source, plugin]),
)
const pluginIds = new Set<string>([
...Object.keys(installedData.plugins),
...allLoaded
.filter((plugin) => !plugin.source.endsWith('@inline'))
.map((plugin) => plugin.source),
])
const detailById = new Map<string, ApiPluginDetail>()
for (const pluginId of [...pluginIds].sort()) {
const installation = this.pickInstallation(
installedData.plugins[pluginId] ?? [],
cwd,
)
const loaded = loadedById.get(pluginId)
const detail = await this.serializePluginDetail(
pluginId,
installation,
loaded,
pluginState.errors,
)
detailById.set(pluginId, detail)
}
const plugins = [...detailById.values()].map((detail) =>
this.toSummary(detail),
)
const marketplaces = Object.entries(marketplaceConfig.marketplaces ?? {})
.map(([name, entry]) => ({
name,
source: getMarketplaceSourceDisplay(entry.source),
lastUpdated: entry.lastUpdated,
autoUpdate: entry.autoUpdate !== false,
installedCount: plugins.filter((plugin) => plugin.marketplace === name).length,
}))
.sort((a, b) => a.name.localeCompare(b.name))
return { plugins, detailById, marketplaces }
}
private async loadPluginState(): Promise<HydratedPluginState> {
const result = await loadAllPlugins()
await Promise.all(
result.enabled.map(async (plugin) => {
plugin.mcpServers = plugin.mcpServers || await loadPluginMcpServers(plugin, result.errors)
plugin.lspServers = plugin.lspServers || await loadPluginLspServers(plugin, result.errors)
}),
)
return result
}
private async serializePluginDetail(
pluginId: string,
installation: PluginInstallationEntry | null,
loaded: LoadedPlugin | undefined,
errors: PluginError[],
): Promise<ApiPluginDetail> {
const { name, marketplace } = parsePluginIdentifier(pluginId)
const pluginErrors = this.getErrorsForPlugin(pluginId, name, errors)
if (!loaded) {
return {
id: pluginId,
name,
marketplace: marketplace || 'unknown',
scope: installation?.scope ?? 'user',
enabled: false,
hasErrors: pluginErrors.length > 0,
isBuiltin: false,
installPath: installation?.installPath,
projectPath: installation?.projectPath,
errors: pluginErrors,
componentCounts: this.countCapabilities(this.emptyCapabilities()),
capabilities: this.emptyCapabilities(),
commandEntries: [],
agentEntries: [],
hookEntries: [],
skillEntries: [],
mcpServerEntries: [],
}
}
const {
capabilities,
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
} = await this.collectCapabilities(loaded)
return {
id: pluginId,
name: loaded.name,
marketplace: marketplace || 'unknown',
scope: installation?.scope ?? 'user',
enabled: loaded.enabled !== false,
hasErrors: pluginErrors.length > 0,
isBuiltin: Boolean(loaded.isBuiltin),
version: loaded.manifest.version,
description: loaded.manifest.description,
authorName: loaded.manifest.author?.name,
installPath: installation?.installPath,
projectPath: installation?.projectPath,
errors: pluginErrors,
componentCounts: this.countCapabilities(capabilities),
capabilities,
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
}
}
private async collectCapabilities(
plugin: LoadedPlugin,
): Promise<{
capabilities: ApiPluginCapabilitySet
commandEntries: ApiPluginCommandEntry[]
agentEntries: ApiPluginAgentEntry[]
hookEntries: ApiPluginHookEntry[]
skillEntries: ApiPluginSkillEntry[]
mcpServerEntries: ApiPluginMcpServerEntry[]
}> {
if (plugin.isBuiltin) {
const definition = getBuiltinPluginDefinition(plugin.name)
const skillEntries = (definition?.skills ?? []).map((skill) => ({
name: skill.name,
description: skill.description,
}))
const mcpServerEntries = Object.entries(definition?.mcpServers ?? {}).map(([serverName, config]) => ({
name: `plugin:${plugin.name}:${serverName}`,
displayName: serverName,
transport: this.getPluginMcpTransport(config),
summary: this.getPluginMcpSummary(config),
}))
return {
capabilities: {
commands: [],
agents: [],
skills: skillEntries.map((skill) => skill.name),
hooks: definition?.hooks ? Object.keys(definition.hooks) : [],
mcpServers: mcpServerEntries.map((server) => server.name),
lspServers: [],
},
commandEntries: [],
agentEntries: [],
hookEntries: this.collectHookEntries(definition?.hooks),
skillEntries,
mcpServerEntries,
}
}
const commandEntries = await this.collectCommandEntries(plugin)
const agentEntries = await this.collectAgentEntries(plugin)
const hookEntries = this.collectHookEntries(plugin.hooksConfig)
const skillEntries = await this.collectSkillEntries([
plugin.skillsPath,
...(plugin.skillsPaths ?? []),
], plugin.name)
const mcpServerEntries = this.collectMcpServerEntries(plugin.name, plugin.mcpServers)
return {
capabilities: {
commands: commandEntries.map((command) => command.name),
agents: agentEntries.map((agent) => agent.name),
skills: skillEntries.map((skill) => skill.name),
hooks: [...new Set(hookEntries.map((hook) => hook.event))],
mcpServers: mcpServerEntries.map((server) => server.name),
lspServers: plugin.lspServers ? Object.keys(plugin.lspServers) : [],
},
commandEntries,
agentEntries,
hookEntries,
skillEntries,
mcpServerEntries,
}
}
private async collectCommandEntries(
plugin: LoadedPlugin,
): Promise<ApiPluginCommandEntry[]> {
return this.collectMarkdownEntriesWithDescriptions(
plugin.name,
[plugin.commandsPath, ...(plugin.commandsPaths ?? [])],
{ stopAtSkillDir: true, useNamespace: true },
)
}
private async collectAgentEntries(
plugin: LoadedPlugin,
): Promise<ApiPluginAgentEntry[]> {
return this.collectMarkdownEntriesWithDescriptions(
plugin.name,
[plugin.agentsPath, ...(plugin.agentsPaths ?? [])],
{ stopAtSkillDir: false, useNamespace: true, preferFrontmatterName: true },
)
}
private async collectMarkdownEntries(paths: Array<string | undefined>): Promise<string[]> {
const fs = await import('node:fs/promises')
const names = new Set<string>()
for (const dirPath of paths) {
if (!dirPath) continue
try {
const dirEntries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
names.add(entry.name.replace(/\.md$/i, ''))
}
} catch {
// Ignore unreadable plugin component directories and keep rendering.
}
}
return [...names].sort()
}
private async collectMarkdownEntriesWithDescriptions(
pluginName: string,
paths: Array<string | undefined>,
options: {
stopAtSkillDir: boolean
useNamespace: boolean
preferFrontmatterName?: boolean
},
): Promise<Array<{ name: string; displayName?: string; description: string }>> {
const fs = await import('node:fs/promises')
const entries = new Map<string, { name: string; displayName?: string; description: string }>()
for (const rootPath of paths) {
if (!rootPath) continue
await walkPluginMarkdown(
rootPath,
async (fullPath, namespace) => {
const raw = await fs.readFile(fullPath, 'utf-8')
const parsed = parseFrontmatter(raw, fullPath)
const baseName = basename(fullPath).replace(/\.md$/i, '')
const resolvedLeafName =
options.preferFrontmatterName &&
typeof parsed.frontmatter.name === 'string' &&
parsed.frontmatter.name.trim().length > 0
? parsed.frontmatter.name.trim()
: /^skill$/i.test(baseName)
? basename(join(fullPath, '..'))
: baseName
const leafName = resolvedLeafName
const name = options.useNamespace && namespace.length > 0
? `${pluginName}:${namespace.join(':')}:${leafName}`
: options.useNamespace
? `${pluginName}:${leafName}`
: leafName
const description =
(typeof parsed.frontmatter.description === 'string' && parsed.frontmatter.description.trim()) ||
(typeof parsed.frontmatter['when-to-use'] === 'string' && parsed.frontmatter['when-to-use'].trim()) ||
extractDescriptionFromMarkdown(parsed.content, 'No description')
if (!entries.has(name)) {
entries.set(name, {
name,
displayName: leafName !== name ? leafName : undefined,
description,
})
}
},
{
stopAtSkillDir: options.stopAtSkillDir,
logLabel: options.useNamespace ? 'plugin-details' : 'markdown',
},
)
}
return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name))
}
private async collectSkillEntries(
paths: Array<string | undefined>,
pluginName: string,
): Promise<ApiPluginSkillEntry[]> {
const fs = await import('node:fs/promises')
const skillEntriesByName = new Map<string, ApiPluginSkillEntry>()
for (const dirPath of paths) {
if (!dirPath) continue
try {
const directSkill = await this.readPluginSkillEntry(dirPath, pluginName)
if (directSkill && !skillEntriesByName.has(directSkill.name)) {
skillEntriesByName.set(directSkill.name, directSkill)
continue
}
} catch {
// Fall back to scanning as a skill root.
}
try {
const dirEntries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
try {
const skillEntry = await this.readPluginSkillEntry(join(dirPath, entry.name), pluginName)
if (skillEntry && !skillEntriesByName.has(skillEntry.name)) {
skillEntriesByName.set(skillEntry.name, skillEntry)
}
} catch {
// Ignore non-skill directories.
}
}
} catch {
// Ignore unreadable plugin component directories and keep rendering.
}
}
return [...skillEntriesByName.values()].sort((a, b) => a.name.localeCompare(b.name))
}
private async readPluginSkillEntry(
skillDir: string,
pluginName: string,
): Promise<ApiPluginSkillEntry | null> {
const fs = await import('node:fs/promises')
const skillFile = join(skillDir, 'SKILL.md')
try {
const stat = await fs.stat(skillFile)
if (!stat.isFile()) return null
const raw = await fs.readFile(skillFile, 'utf-8')
const parsed = parseFrontmatter(raw, skillFile)
const body = parsed.content
const name = typeof parsed.frontmatter.name === 'string' && parsed.frontmatter.name.trim().length > 0
? parsed.frontmatter.name.trim()
: basename(skillDir)
const description =
(typeof parsed.frontmatter.description === 'string' && parsed.frontmatter.description.trim()) ||
body
.split('\n')
.find((line) => line.trim().length > 0)
?.trim() ||
'No description'
return {
name: `${pluginName}:${basename(skillDir)}`,
displayName: name !== basename(skillDir) ? name : undefined,
description,
version: parsed.frontmatter.version != null ? String(parsed.frontmatter.version) : undefined,
pluginName,
}
} catch {
return null
}
}
private collectMcpServerEntries(
pluginName: string,
servers?: Record<string, McpServerConfig>,
): ApiPluginMcpServerEntry[] {
return Object.entries(servers ?? {})
.map(([name, config]) => ({
name: `plugin:${pluginName}:${name}`,
displayName: name,
transport: this.getPluginMcpTransport(config),
summary: this.getPluginMcpSummary(config),
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
private collectHookEntries(hooks?: HooksSettings): ApiPluginHookEntry[] {
const entries: ApiPluginHookEntry[] = []
for (const [event, matchers] of Object.entries(hooks ?? {})) {
for (const matcher of matchers ?? []) {
entries.push({
event,
matcher: matcher.matcher,
actions: matcher.hooks.map((hook) => this.describeHookAction(hook)),
})
}
}
return entries.sort((a, b) => {
if (a.event !== b.event) return a.event.localeCompare(b.event)
return (a.matcher ?? '').localeCompare(b.matcher ?? '')
})
}
private describeHookAction(hook: HookCommand): string {
switch (hook.type) {
case 'command':
return hook.command
case 'prompt':
return hook.prompt
case 'agent':
return hook.prompt
case 'http':
return hook.url
default:
return hook.type
}
}
private getPluginMcpTransport(config: McpServerConfig): string {
return config.type ?? 'stdio'
}
private getPluginMcpSummary(config: McpServerConfig): string {
if (!config.type || config.type === 'stdio') {
const stdioConfig = config as McpServerConfig & {
command?: string
args?: string[]
}
return [stdioConfig.command, ...(stdioConfig.args ?? [])].filter(Boolean).join(' ').trim()
}
if ('url' in config && typeof config.url === 'string') {
return config.url
}
return config.type
}
private getErrorsForPlugin(
pluginId: string,
pluginName: string,
errors: PluginError[],
): string[] {
return errors
.filter((error) => {
if (error.source === pluginId) return true
if ('plugin' in error && error.plugin === pluginName) return true
return error.source.startsWith(`${pluginName}@`)
})
.map(getPluginErrorMessage)
}
private pickInstallation(
installations: PluginInstallationEntry[],
cwd?: string,
): PluginInstallationEntry | null {
if (!installations.length) return null
const relevantForCwd = cwd
? installations.filter((entry) =>
entry.projectPath ? this.isPathWithinProject(cwd, entry.projectPath) : false,
)
: []
const localMatch = relevantForCwd.find((entry) => entry.scope === 'local')
if (localMatch) return localMatch
const projectMatch = relevantForCwd.find((entry) => entry.scope === 'project')
if (projectMatch) return projectMatch
const userMatch = installations.find((entry) => entry.scope === 'user')
if (userMatch) return userMatch
return installations[0] ?? null
}
private isPathWithinProject(cwd: string, projectPath: string): boolean {
return cwd === projectPath || cwd.startsWith(`${projectPath}${sep}`)
}
private emptyCapabilities(): ApiPluginCapabilitySet {
return {
commands: [],
agents: [],
skills: [],
hooks: [],
mcpServers: [],
lspServers: [],
}
}
private countCapabilities(
capabilities: ApiPluginCapabilitySet,
): Record<keyof ApiPluginCapabilitySet, number> {
return {
commands: capabilities.commands.length,
agents: capabilities.agents.length,
skills: capabilities.skills.length,
hooks: capabilities.hooks.length,
mcpServers: capabilities.mcpServers.length,
lspServers: capabilities.lspServers.length,
}
}
private toSummary(detail: ApiPluginDetail): ApiPluginSummary {
return {
id: detail.id,
name: detail.name,
marketplace: detail.marketplace,
scope: detail.scope,
enabled: detail.enabled,
hasErrors: detail.hasErrors,
isBuiltin: detail.isBuiltin,
version: detail.version,
description: detail.description,
authorName: detail.authorName,
installPath: detail.installPath,
projectPath: detail.projectPath,
componentCounts: detail.componentCounts,
errors: detail.errors,
}
}
private async getHookCount(plugins: LoadedPlugin[]): Promise<number> {
try {
await loadPluginHooks()
} catch {
// Hook loading failures are already represented in the shared plugin errors.
}
return plugins.reduce((sum, plugin) => {
if (!plugin.hooksConfig) return sum
return sum + Object.values(plugin.hooksConfig).reduce((hookSum, matchers) => (
hookSum + (matchers?.reduce((matcherSum, matcher) => matcherSum + matcher.hooks.length, 0) ?? 0)
), 0)
}, 0)
}
}