source dump of claude code
at main 271 lines 8.5 kB view raw
1import { resolve } from 'path' 2import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' 3import { getSessionId } from '../../bootstrap/state.js' 4import type { AppState } from '../../state/AppState.js' 5import type { EditableSettingSource } from '../settings/constants.js' 6import { SOURCES } from '../settings/constants.js' 7import { 8 getSettingsFilePathForSource, 9 getSettingsForSource, 10} from '../settings/settings.js' 11import type { HookCommand, HookMatcher } from '../settings/types.js' 12import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' 13import { getSessionHooks } from './sessionHooks.js' 14 15export type HookSource = 16 | EditableSettingSource 17 | 'policySettings' 18 | 'pluginHook' 19 | 'sessionHook' 20 | 'builtinHook' 21 22export 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 */ 33export 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 */ 68export 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 92export 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 163export function getHooksForEvent( 164 appState: AppState, 165 event: HookEvent, 166): IndividualHookConfig[] { 167 return getAllHooks(appState).filter(hook => hook.event === event) 168} 169 170export 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 192export 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 211export 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 230export 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}