Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/features/claude-code-plugin-loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function getInstalledPluginsPath(): string {
}

function resolvePluginPath(path: string, pluginRoot: string): string {
return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
return path.replaceAll(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
}

function resolvePluginPaths<T>(obj: T, pluginRoot: string): T {
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/claude-code-hooks/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"

export interface DisabledHooksConfig {
SessionStart?: string[]
Stop?: string[]
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
PreCompact?: string[]
SessionEnd?: string[]
}

export interface PluginExtendedConfig {
Expand Down Expand Up @@ -44,11 +46,13 @@ function mergeDisabledHooks(
if (!base) return override

return {
SessionStart: override.SessionStart ?? base.SessionStart,
Stop: override.Stop ?? base.Stop,
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
PreCompact: override.PreCompact ?? base.PreCompact,
SessionEnd: override.SessionEnd ?? base.SessionEnd,
}
}

Expand Down
128 changes: 127 additions & 1 deletion src/hooks/claude-code-hooks/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { join } from "path"
import { existsSync } from "fs"
import { existsSync, readFileSync, readdirSync } from "fs"
import { homedir } from "os"
import { getClaudeConfigDir } from "../../shared"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"

const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"

interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
}

interface RawClaudeHooksConfig {
SessionStart?: RawHookMatcher[]
PreToolUse?: RawHookMatcher[]
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
PreCompact?: RawHookMatcher[]
SessionEnd?: RawHookMatcher[]
}

function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
Expand All @@ -27,11 +32,13 @@ function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
const result: ClaudeHooksConfig = {}
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
"SessionStart",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
"SessionEnd",
]

for (const eventType of eventTypes) {
Expand Down Expand Up @@ -64,11 +71,13 @@ function mergeHooksConfig(
): ClaudeHooksConfig {
const result: ClaudeHooksConfig = { ...base }
const eventTypes: (keyof ClaudeHooksConfig)[] = [
"SessionStart",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
"SessionEnd",
]
for (const eventType of eventTypes) {
if (override[eventType]) {
Expand Down Expand Up @@ -99,5 +108,122 @@ export async function loadClaudeHooksConfig(
}
}

const pluginHooks = await loadPluginHooksConfigs()
mergedConfig = mergeHooksConfig(mergedConfig, pluginHooks)

return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
}

function resolvePluginPath(path: string, pluginRoot: string): string {
return path.replaceAll(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
}

function resolvePluginPaths<T>(obj: T, pluginRoot: string): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") {
return resolvePluginPath(obj, pluginRoot) as T
}
if (Array.isArray(obj)) {
return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = resolvePluginPaths(value, pluginRoot)
}
return result as T
}
return obj
}

interface PluginInstallation {
installPath: string
}

interface InstalledPluginsDatabase {
version: number
plugins: Record<string, PluginInstallation | PluginInstallation[]>
}

interface ClaudeSettings {
enabledPlugins?: Record<string, boolean>
}

interface PluginHooksJson {
hooks?: RawClaudeHooksConfig
}

function getPluginsBaseDir(): string {
if (process.env.CLAUDE_PLUGINS_HOME) {
return process.env.CLAUDE_PLUGINS_HOME
}
return join(homedir(), ".claude", "plugins")
}

function isPluginEnabled(
pluginKey: string,
enabledPlugins: Record<string, boolean> | undefined
): boolean {
if (enabledPlugins && pluginKey in enabledPlugins) {
return enabledPlugins[pluginKey]
}
return true
}

async function loadPluginHooksConfigs(): Promise<ClaudeHooksConfig> {
let mergedConfig: ClaudeHooksConfig = {}

const dbPath = join(getPluginsBaseDir(), "installed_plugins.json")
if (!existsSync(dbPath)) {
return mergedConfig
}

let db: InstalledPluginsDatabase
try {
const content = readFileSync(dbPath, "utf-8")
db = JSON.parse(content) as InstalledPluginsDatabase
} catch {
return mergedConfig
}

let enabledPlugins: Record<string, boolean> | undefined
const settingsPath = join(homedir(), ".claude", "settings.json")
if (existsSync(settingsPath)) {
try {
const content = readFileSync(settingsPath, "utf-8")
const settings = JSON.parse(content) as ClaudeSettings
enabledPlugins = settings.enabledPlugins
} catch {
}
}

for (const [pluginKey, installations] of Object.entries(db.plugins)) {
if (!isPluginEnabled(pluginKey, enabledPlugins)) {
continue
}

const installation = Array.isArray(installations) ? installations[0] : installations
if (!installation?.installPath) continue

const { installPath } = installation
if (!existsSync(installPath)) continue

const hooksPath = join(installPath, "hooks", "hooks.json")
if (!existsSync(hooksPath)) continue

try {
const content = readFileSync(hooksPath, "utf-8")
let config = JSON.parse(content) as PluginHooksJson
config = resolvePluginPaths(config, installPath)

if (config.hooks) {
const normalizedHooks = normalizeHooksConfig(config.hooks)
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
}
} catch {
continue
}
}

return mergedConfig
}
Loading