| import { resolve } from 'path' |
| import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' |
| import { getSessionId } from '../../bootstrap/state.js' |
| import type { AppState } from '../../state/AppState.js' |
| import type { EditableSettingSource } from '../settings/constants.js' |
| import { SOURCES } from '../settings/constants.js' |
| import { |
| getSettingsFilePathForSource, |
| getSettingsForSource, |
| } from '../settings/settings.js' |
| import type { HookCommand, HookMatcher } from '../settings/types.js' |
| import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' |
| import { getSessionHooks } from './sessionHooks.js' |
|
|
| export type HookSource = |
| | EditableSettingSource |
| | 'policySettings' |
| | 'pluginHook' |
| | 'sessionHook' |
| | 'builtinHook' |
|
|
| export interface IndividualHookConfig { |
| event: HookEvent |
| config: HookCommand |
| matcher?: string |
| source: HookSource |
| pluginName?: string |
| } |
|
|
| |
| |
| |
| export function isHookEqual( |
| a: HookCommand | { type: 'function'; timeout?: number }, |
| b: HookCommand | { type: 'function'; timeout?: number }, |
| ): boolean { |
| if (a.type !== b.type) return false |
|
|
| |
| |
| |
| |
| const sameIf = (x: { if?: string }, y: { if?: string }) => |
| (x.if ?? '') === (y.if ?? '') |
| switch (a.type) { |
| case 'command': |
| |
| |
| return ( |
| b.type === 'command' && |
| a.command === b.command && |
| (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) && |
| sameIf(a, b) |
| ) |
| case 'prompt': |
| return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b) |
| case 'agent': |
| return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b) |
| case 'http': |
| return b.type === 'http' && a.url === b.url && sameIf(a, b) |
| case 'function': |
| |
| return false |
| } |
| } |
|
|
| |
| export function getHookDisplayText( |
| hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string }, |
| ): string { |
| |
| if ('statusMessage' in hook && hook.statusMessage) { |
| return hook.statusMessage |
| } |
|
|
| switch (hook.type) { |
| case 'command': |
| return hook.command |
| case 'prompt': |
| return hook.prompt |
| case 'agent': |
| return hook.prompt |
| case 'http': |
| return hook.url |
| case 'callback': |
| return 'callback' |
| case 'function': |
| return 'function' |
| } |
| } |
|
|
| export function getAllHooks(appState: AppState): IndividualHookConfig[] { |
| const hooks: IndividualHookConfig[] = [] |
|
|
| |
| const policySettings = getSettingsForSource('policySettings') |
| const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true |
|
|
| |
| |
| if (!restrictedToManagedOnly) { |
| |
| const sources = [ |
| 'userSettings', |
| 'projectSettings', |
| 'localSettings', |
| ] as EditableSettingSource[] |
|
|
| |
| |
| |
| const seenFiles = new Set<string>() |
|
|
| for (const source of sources) { |
| const filePath = getSettingsFilePathForSource(source) |
| if (filePath) { |
| const resolvedPath = resolve(filePath) |
| if (seenFiles.has(resolvedPath)) { |
| continue |
| } |
| seenFiles.add(resolvedPath) |
| } |
|
|
| const sourceSettings = getSettingsForSource(source) |
| if (!sourceSettings?.hooks) { |
| continue |
| } |
|
|
| for (const [event, matchers] of Object.entries(sourceSettings.hooks)) { |
| for (const matcher of matchers as HookMatcher[]) { |
| for (const hookCommand of matcher.hooks) { |
| hooks.push({ |
| event: event as HookEvent, |
| config: hookCommand, |
| matcher: matcher.matcher, |
| source, |
| }) |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| const sessionId = getSessionId() |
| const sessionHooks = getSessionHooks(appState, sessionId) |
| for (const [event, matchers] of sessionHooks.entries()) { |
| for (const matcher of matchers) { |
| for (const hookCommand of matcher.hooks) { |
| hooks.push({ |
| event, |
| config: hookCommand, |
| matcher: matcher.matcher, |
| source: 'sessionHook', |
| }) |
| } |
| } |
| } |
|
|
| return hooks |
| } |
|
|
| export function getHooksForEvent( |
| appState: AppState, |
| event: HookEvent, |
| ): IndividualHookConfig[] { |
| return getAllHooks(appState).filter(hook => hook.event === event) |
| } |
|
|
| export function hookSourceDescriptionDisplayString(source: HookSource): string { |
| switch (source) { |
| case 'userSettings': |
| return 'User settings (~/.claude/settings.json)' |
| case 'projectSettings': |
| return 'Project settings (.claude/settings.json)' |
| case 'localSettings': |
| return 'Local settings (.claude/settings.local.json)' |
| case 'pluginHook': |
| |
| |
| |
| return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' |
| case 'sessionHook': |
| return 'Session hooks (in-memory, temporary)' |
| case 'builtinHook': |
| return 'Built-in hooks (registered internally by Claude Code)' |
| default: |
| return source as string |
| } |
| } |
|
|
| export function hookSourceHeaderDisplayString(source: HookSource): string { |
| switch (source) { |
| case 'userSettings': |
| return 'User Settings' |
| case 'projectSettings': |
| return 'Project Settings' |
| case 'localSettings': |
| return 'Local Settings' |
| case 'pluginHook': |
| return 'Plugin Hooks' |
| case 'sessionHook': |
| return 'Session Hooks' |
| case 'builtinHook': |
| return 'Built-in Hooks' |
| default: |
| return source as string |
| } |
| } |
|
|
| export function hookSourceInlineDisplayString(source: HookSource): string { |
| switch (source) { |
| case 'userSettings': |
| return 'User' |
| case 'projectSettings': |
| return 'Project' |
| case 'localSettings': |
| return 'Local' |
| case 'pluginHook': |
| return 'Plugin' |
| case 'sessionHook': |
| return 'Session' |
| case 'builtinHook': |
| return 'Built-in' |
| default: |
| return source as string |
| } |
| } |
|
|
| export function sortMatchersByPriority( |
| matchers: string[], |
| hooksByEventAndMatcher: Record< |
| string, |
| Record<string, IndividualHookConfig[]> |
| >, |
| selectedEvent: HookEvent, |
| ): string[] { |
| |
| const sourcePriority = SOURCES.reduce( |
| (acc, source, index) => { |
| acc[source] = index |
| return acc |
| }, |
| {} as Record<EditableSettingSource, number>, |
| ) |
|
|
| return [...matchers].sort((a, b) => { |
| const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || [] |
| const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || [] |
|
|
| const aSources = Array.from(new Set(aHooks.map(h => h.source))) |
| const bSources = Array.from(new Set(bHooks.map(h => h.source))) |
|
|
| |
| |
| const getSourcePriority = (source: HookSource) => |
| source === 'pluginHook' || source === 'builtinHook' |
| ? 999 |
| : sourcePriority[source as EditableSettingSource] |
|
|
| const aHighestPriority = Math.min(...aSources.map(getSourcePriority)) |
| const bHighestPriority = Math.min(...bSources.map(getSourcePriority)) |
|
|
| if (aHighestPriority !== bHighestPriority) { |
| return aHighestPriority - bHighestPriority |
| } |
|
|
| |
| return a.localeCompare(b) |
| }) |
| } |
|
|