When cwd equals home directory, ~/.claude/settings.json was being loaded twice (once as home config and once as cwd config), causing hooks like Stop to execute twice. This adds deduplication using Set to ensure each config file is only loaded once.
106 lines
2.8 KiB
TypeScript
106 lines
2.8 KiB
TypeScript
import { join } from "path"
|
|
import { existsSync } from "fs"
|
|
import { getClaudeConfigDir } from "../../shared"
|
|
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
|
|
|
|
interface RawHookMatcher {
|
|
matcher?: string
|
|
pattern?: string
|
|
hooks: HookCommand[]
|
|
}
|
|
|
|
interface RawClaudeHooksConfig {
|
|
PreToolUse?: RawHookMatcher[]
|
|
PostToolUse?: RawHookMatcher[]
|
|
UserPromptSubmit?: RawHookMatcher[]
|
|
Stop?: RawHookMatcher[]
|
|
PreCompact?: RawHookMatcher[]
|
|
}
|
|
|
|
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
|
|
return {
|
|
matcher: raw.matcher ?? raw.pattern ?? "*",
|
|
hooks: raw.hooks,
|
|
}
|
|
}
|
|
|
|
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
|
|
const result: ClaudeHooksConfig = {}
|
|
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"UserPromptSubmit",
|
|
"Stop",
|
|
"PreCompact",
|
|
]
|
|
|
|
for (const eventType of eventTypes) {
|
|
if (raw[eventType]) {
|
|
result[eventType] = raw[eventType].map(normalizeHookMatcher)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function getClaudeSettingsPaths(customPath?: string): string[] {
|
|
const claudeConfigDir = getClaudeConfigDir()
|
|
const paths = [
|
|
join(claudeConfigDir, "settings.json"),
|
|
join(process.cwd(), ".claude", "settings.json"),
|
|
join(process.cwd(), ".claude", "settings.local.json"),
|
|
]
|
|
|
|
if (customPath && existsSync(customPath)) {
|
|
paths.unshift(customPath)
|
|
}
|
|
|
|
// Deduplicate paths to prevent loading the same file multiple times
|
|
// (e.g., when cwd is the home directory)
|
|
return [...new Set(paths)]
|
|
}
|
|
|
|
function mergeHooksConfig(
|
|
base: ClaudeHooksConfig,
|
|
override: ClaudeHooksConfig
|
|
): ClaudeHooksConfig {
|
|
const result: ClaudeHooksConfig = { ...base }
|
|
const eventTypes: (keyof ClaudeHooksConfig)[] = [
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"UserPromptSubmit",
|
|
"Stop",
|
|
"PreCompact",
|
|
]
|
|
for (const eventType of eventTypes) {
|
|
if (override[eventType]) {
|
|
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function loadClaudeHooksConfig(
|
|
customSettingsPath?: string
|
|
): Promise<ClaudeHooksConfig | null> {
|
|
const paths = getClaudeSettingsPaths(customSettingsPath)
|
|
let mergedConfig: ClaudeHooksConfig = {}
|
|
|
|
for (const settingsPath of paths) {
|
|
if (existsSync(settingsPath)) {
|
|
try {
|
|
const content = await Bun.file(settingsPath).text()
|
|
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
|
if (settings.hooks) {
|
|
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
|
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
|
}
|