/ utils / hooks / hooksSettings.ts
hooksSettings.ts
  1  import { resolve } from 'path'
  2  import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
  3  import { getSessionId } from '../../bootstrap/state.js'
  4  import type { AppState } from '../../state/AppState.js'
  5  import type { EditableSettingSource } from '../settings/constants.js'
  6  import { SOURCES } from '../settings/constants.js'
  7  import {
  8    getSettingsFilePathForSource,
  9    getSettingsForSource,
 10  } from '../settings/settings.js'
 11  import type { HookCommand, HookMatcher } from '../settings/types.js'
 12  import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js'
 13  import { getSessionHooks } from './sessionHooks.js'
 14  
 15  export type HookSource =
 16    | EditableSettingSource
 17    | 'policySettings'
 18    | 'pluginHook'
 19    | 'sessionHook'
 20    | 'builtinHook'
 21  
 22  export interface IndividualHookConfig {
 23    event: HookEvent
 24    config: HookCommand
 25    matcher?: string
 26    source: HookSource
 27    pluginName?: string
 28  }
 29  
 30  /**
 31   * Check if two hooks are equal (comparing only command/prompt content, not timeout)
 32   */
 33  export function isHookEqual(
 34    a: HookCommand | { type: 'function'; timeout?: number },
 35    b: HookCommand | { type: 'function'; timeout?: number },
 36  ): boolean {
 37    if (a.type !== b.type) return false
 38  
 39    // Use switch for exhaustive type checking
 40    // Note: We only compare command/prompt content, not timeout
 41    // `if` is part of identity: same command with different `if` conditions
 42    // are distinct hooks (e.g., setup.sh if=Bash(git *) vs if=Bash(npm *)).
 43    const sameIf = (x: { if?: string }, y: { if?: string }) =>
 44      (x.if ?? '') === (y.if ?? '')
 45    switch (a.type) {
 46      case 'command':
 47        // shell is part of identity: same command string with different
 48        // shells are distinct hooks. Default 'bash' so undefined === 'bash'.
 49        return (
 50          b.type === 'command' &&
 51          a.command === b.command &&
 52          (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) &&
 53          sameIf(a, b)
 54        )
 55      case 'prompt':
 56        return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b)
 57      case 'agent':
 58        return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b)
 59      case 'http':
 60        return b.type === 'http' && a.url === b.url && sameIf(a, b)
 61      case 'function':
 62        // Function hooks can't be compared (no stable identifier)
 63        return false
 64    }
 65  }
 66  
 67  /** Get the display text for a hook */
 68  export function getHookDisplayText(
 69    hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string },
 70  ): string {
 71    // Return custom status message if provided
 72    if ('statusMessage' in hook && hook.statusMessage) {
 73      return hook.statusMessage
 74    }
 75  
 76    switch (hook.type) {
 77      case 'command':
 78        return hook.command
 79      case 'prompt':
 80        return hook.prompt
 81      case 'agent':
 82        return hook.prompt
 83      case 'http':
 84        return hook.url
 85      case 'callback':
 86        return 'callback'
 87      case 'function':
 88        return 'function'
 89    }
 90  }
 91  
 92  export function getAllHooks(appState: AppState): IndividualHookConfig[] {
 93    const hooks: IndividualHookConfig[] = []
 94  
 95    // Check if restricted to managed hooks only
 96    const policySettings = getSettingsForSource('policySettings')
 97    const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true
 98  
 99    // If allowManagedHooksOnly is set, don't show any hooks in the UI
100    // (user/project/local are blocked, and managed hooks are intentionally hidden)
101    if (!restrictedToManagedOnly) {
102      // Get hooks from all editable sources
103      const sources = [
104        'userSettings',
105        'projectSettings',
106        'localSettings',
107      ] as EditableSettingSource[]
108  
109      // Track which settings files we've already processed to avoid duplicates
110      // (e.g., when running from home directory, userSettings and projectSettings
111      // both resolve to ~/.claude/settings.json)
112      const seenFiles = new Set<string>()
113  
114      for (const source of sources) {
115        const filePath = getSettingsFilePathForSource(source)
116        if (filePath) {
117          const resolvedPath = resolve(filePath)
118          if (seenFiles.has(resolvedPath)) {
119            continue
120          }
121          seenFiles.add(resolvedPath)
122        }
123  
124        const sourceSettings = getSettingsForSource(source)
125        if (!sourceSettings?.hooks) {
126          continue
127        }
128  
129        for (const [event, matchers] of Object.entries(sourceSettings.hooks)) {
130          for (const matcher of matchers as HookMatcher[]) {
131            for (const hookCommand of matcher.hooks) {
132              hooks.push({
133                event: event as HookEvent,
134                config: hookCommand,
135                matcher: matcher.matcher,
136                source,
137              })
138            }
139          }
140        }
141      }
142    }
143  
144    // Get session hooks
145    const sessionId = getSessionId()
146    const sessionHooks = getSessionHooks(appState, sessionId)
147    for (const [event, matchers] of sessionHooks.entries()) {
148      for (const matcher of matchers) {
149        for (const hookCommand of matcher.hooks) {
150          hooks.push({
151            event,
152            config: hookCommand,
153            matcher: matcher.matcher,
154            source: 'sessionHook',
155          })
156        }
157      }
158    }
159  
160    return hooks
161  }
162  
163  export function getHooksForEvent(
164    appState: AppState,
165    event: HookEvent,
166  ): IndividualHookConfig[] {
167    return getAllHooks(appState).filter(hook => hook.event === event)
168  }
169  
170  export function hookSourceDescriptionDisplayString(source: HookSource): string {
171    switch (source) {
172      case 'userSettings':
173        return 'User settings (~/.claude/settings.json)'
174      case 'projectSettings':
175        return 'Project settings (.claude/settings.json)'
176      case 'localSettings':
177        return 'Local settings (.claude/settings.local.json)'
178      case 'pluginHook':
179        // TODO: Get the actual plugin hook file paths instead of using glob pattern
180        // We should capture the specific plugin paths during hook registration and display them here
181        // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)"
182        return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)'
183      case 'sessionHook':
184        return 'Session hooks (in-memory, temporary)'
185      case 'builtinHook':
186        return 'Built-in hooks (registered internally by Claude Code)'
187      default:
188        return source as string
189    }
190  }
191  
192  export function hookSourceHeaderDisplayString(source: HookSource): string {
193    switch (source) {
194      case 'userSettings':
195        return 'User Settings'
196      case 'projectSettings':
197        return 'Project Settings'
198      case 'localSettings':
199        return 'Local Settings'
200      case 'pluginHook':
201        return 'Plugin Hooks'
202      case 'sessionHook':
203        return 'Session Hooks'
204      case 'builtinHook':
205        return 'Built-in Hooks'
206      default:
207        return source as string
208    }
209  }
210  
211  export function hookSourceInlineDisplayString(source: HookSource): string {
212    switch (source) {
213      case 'userSettings':
214        return 'User'
215      case 'projectSettings':
216        return 'Project'
217      case 'localSettings':
218        return 'Local'
219      case 'pluginHook':
220        return 'Plugin'
221      case 'sessionHook':
222        return 'Session'
223      case 'builtinHook':
224        return 'Built-in'
225      default:
226        return source as string
227    }
228  }
229  
230  export function sortMatchersByPriority(
231    matchers: string[],
232    hooksByEventAndMatcher: Record<
233      string,
234      Record<string, IndividualHookConfig[]>
235    >,
236    selectedEvent: HookEvent,
237  ): string[] {
238    // Create a priority map based on SOURCES order (lower index = higher priority)
239    const sourcePriority = SOURCES.reduce(
240      (acc, source, index) => {
241        acc[source] = index
242        return acc
243      },
244      {} as Record<EditableSettingSource, number>,
245    )
246  
247    return [...matchers].sort((a, b) => {
248      const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || []
249      const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || []
250  
251      const aSources = Array.from(new Set(aHooks.map(h => h.source)))
252      const bSources = Array.from(new Set(bHooks.map(h => h.source)))
253  
254      // Sort by highest priority source first (lowest priority number)
255      // Plugin hooks get lowest priority (highest number)
256      const getSourcePriority = (source: HookSource) =>
257        source === 'pluginHook' || source === 'builtinHook'
258          ? 999
259          : sourcePriority[source as EditableSettingSource]
260  
261      const aHighestPriority = Math.min(...aSources.map(getSourcePriority))
262      const bHighestPriority = Math.min(...bSources.map(getSourcePriority))
263  
264      if (aHighestPriority !== bHighestPriority) {
265        return aHighestPriority - bHighestPriority
266      }
267  
268      // If same priority, sort by matcher name
269      return a.localeCompare(b)
270    })
271  }