Skip to main content

ai_agent/utils/hooks/
hooks_config_manager.rs

1// Source: ~/claudecode/openclaudecode/src/utils/hooks/hooksConfigManager.ts
2#![allow(dead_code)]
3
4use std::collections::HashMap;
5
6/// Hook event type
7#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
8pub enum HookEvent {
9    PreToolUse,
10    PostToolUse,
11    PostToolUseFailure,
12    PermissionDenied,
13    Notification,
14    UserPromptSubmit,
15    SessionStart,
16    SessionEnd,
17    Stop,
18    StopFailure,
19    SubagentStart,
20    SubagentStop,
21    PreCompact,
22    PostCompact,
23    PermissionRequest,
24    Setup,
25    TeammateIdle,
26    TaskCreated,
27    TaskCompleted,
28    Elicitation,
29    ElicitationResult,
30    ConfigChange,
31    WorktreeCreate,
32    WorktreeRemove,
33    InstructionsLoaded,
34    CwdChanged,
35    FileChanged,
36}
37
38impl HookEvent {
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            HookEvent::PreToolUse => "PreToolUse",
42            HookEvent::PostToolUse => "PostToolUse",
43            HookEvent::PostToolUseFailure => "PostToolUseFailure",
44            HookEvent::PermissionDenied => "PermissionDenied",
45            HookEvent::Notification => "Notification",
46            HookEvent::UserPromptSubmit => "UserPromptSubmit",
47            HookEvent::SessionStart => "SessionStart",
48            HookEvent::SessionEnd => "SessionEnd",
49            HookEvent::Stop => "Stop",
50            HookEvent::StopFailure => "StopFailure",
51            HookEvent::SubagentStart => "SubagentStart",
52            HookEvent::SubagentStop => "SubagentStop",
53            HookEvent::PreCompact => "PreCompact",
54            HookEvent::PostCompact => "PostCompact",
55            HookEvent::PermissionRequest => "PermissionRequest",
56            HookEvent::Setup => "Setup",
57            HookEvent::TeammateIdle => "TeammateIdle",
58            HookEvent::TaskCreated => "TaskCreated",
59            HookEvent::TaskCompleted => "TaskCompleted",
60            HookEvent::Elicitation => "Elicitation",
61            HookEvent::ElicitationResult => "ElicitationResult",
62            HookEvent::ConfigChange => "ConfigChange",
63            HookEvent::WorktreeCreate => "WorktreeCreate",
64            HookEvent::WorktreeRemove => "WorktreeRemove",
65            HookEvent::InstructionsLoaded => "InstructionsLoaded",
66            HookEvent::CwdChanged => "CwdChanged",
67            HookEvent::FileChanged => "FileChanged",
68        }
69    }
70}
71
72/// All hook events as a static slice
73pub const HOOK_EVENTS: &[HookEvent] = &[
74    HookEvent::PreToolUse,
75    HookEvent::PostToolUse,
76    HookEvent::PostToolUseFailure,
77    HookEvent::PermissionDenied,
78    HookEvent::Notification,
79    HookEvent::UserPromptSubmit,
80    HookEvent::SessionStart,
81    HookEvent::SessionEnd,
82    HookEvent::Stop,
83    HookEvent::StopFailure,
84    HookEvent::SubagentStart,
85    HookEvent::SubagentStop,
86    HookEvent::PreCompact,
87    HookEvent::PostCompact,
88    HookEvent::PermissionRequest,
89    HookEvent::Setup,
90    HookEvent::TeammateIdle,
91    HookEvent::TaskCreated,
92    HookEvent::TaskCompleted,
93    HookEvent::Elicitation,
94    HookEvent::ElicitationResult,
95    HookEvent::ConfigChange,
96    HookEvent::WorktreeCreate,
97    HookEvent::WorktreeRemove,
98    HookEvent::InstructionsLoaded,
99    HookEvent::CwdChanged,
100    HookEvent::FileChanged,
101];
102
103/// Metadata for a hook event matcher
104#[derive(Debug, Clone)]
105pub struct MatcherMetadata {
106    pub field_to_match: String,
107    pub values: Vec<String>,
108}
109
110/// Metadata for a hook event
111#[derive(Debug, Clone)]
112pub struct HookEventMetadata {
113    pub summary: String,
114    pub description: String,
115    pub matcher_metadata: Option<MatcherMetadata>,
116}
117
118/// Individual hook configuration
119#[derive(Debug, Clone)]
120pub struct IndividualHookConfig {
121    pub event: HookEvent,
122    pub config: HookCommand,
123    pub matcher: Option<String>,
124    pub source: HookSource,
125    pub plugin_name: Option<String>,
126}
127
128/// Hook command types
129#[derive(Debug, Clone)]
130pub enum HookCommand {
131    Command {
132        command: String,
133        shell: Option<String>,
134        if_condition: Option<String>,
135        timeout: Option<u64>,
136    },
137    Prompt {
138        prompt: String,
139        if_condition: Option<String>,
140        timeout: Option<u64>,
141    },
142    Agent {
143        prompt: String,
144        model: Option<String>,
145        if_condition: Option<String>,
146        timeout: Option<u64>,
147    },
148    Http {
149        url: String,
150        if_condition: Option<String>,
151        timeout: Option<u64>,
152    },
153}
154
155/// Hook source
156#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub enum HookSource {
158    UserSettings,
159    ProjectSettings,
160    LocalSettings,
161    PluginHook,
162    SessionHook,
163    BuiltinHook,
164}
165
166impl std::fmt::Display for HookSource {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            HookSource::UserSettings => write!(f, "User Settings"),
170            HookSource::ProjectSettings => write!(f, "Project Settings"),
171            HookSource::LocalSettings => write!(f, "Local Settings"),
172            HookSource::PluginHook => write!(f, "Plugin Hooks"),
173            HookSource::SessionHook => write!(f, "Session Hooks"),
174            HookSource::BuiltinHook => write!(f, "Built-in Hooks"),
175        }
176    }
177}
178
179/// Get display text for a hook
180pub fn get_hook_display_text(hook: &HookCommand) -> String {
181    match hook {
182        HookCommand::Command { command, .. } => command.clone(),
183        HookCommand::Prompt { prompt, .. } => prompt.clone(),
184        HookCommand::Agent { prompt, .. } => prompt.clone(),
185        HookCommand::Http { url, .. } => url.clone(),
186    }
187}
188
189/// Get hook event metadata for all events
190pub fn get_hook_event_metadata(tool_names: &[String]) -> HashMap<HookEvent, HookEventMetadata> {
191    let mut metadata = HashMap::new();
192
193    metadata.insert(
194        HookEvent::PreToolUse,
195        HookEventMetadata {
196            summary: "Before tool execution".to_string(),
197            description: "Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call".to_string(),
198            matcher_metadata: Some(MatcherMetadata {
199                field_to_match: "tool_name".to_string(),
200                values: tool_names.to_vec(),
201            }),
202        },
203    );
204
205    metadata.insert(
206        HookEvent::PostToolUse,
207        HookEventMetadata {
208            summary: "After tool execution".to_string(),
209            description: "Input to command is JSON with fields \"inputs\" (tool call arguments) and \"response\" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only".to_string(),
210            matcher_metadata: Some(MatcherMetadata {
211                field_to_match: "tool_name".to_string(),
212                values: tool_names.to_vec(),
213            }),
214        },
215    );
216
217    metadata.insert(
218        HookEvent::PostToolUseFailure,
219        HookEventMetadata {
220            summary: "After tool execution fails".to_string(),
221            description: "Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only".to_string(),
222            matcher_metadata: Some(MatcherMetadata {
223                field_to_match: "tool_name".to_string(),
224                values: tool_names.to_vec(),
225            }),
226        },
227    );
228
229    metadata.insert(
230        HookEvent::PermissionDenied,
231        HookEventMetadata {
232            summary: "After auto mode classifier denies a tool call".to_string(),
233            description: "Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {\"hookSpecificOutput\":{\"hookEventName\":\"PermissionDenied\",\"retry\":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only".to_string(),
234            matcher_metadata: Some(MatcherMetadata {
235                field_to_match: "tool_name".to_string(),
236                values: tool_names.to_vec(),
237            }),
238        },
239    );
240
241    metadata.insert(
242        HookEvent::Notification,
243        HookEventMetadata {
244            summary: "When notifications are sent".to_string(),
245            description: "Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only".to_string(),
246            matcher_metadata: Some(MatcherMetadata {
247                field_to_match: "notification_type".to_string(),
248                values: vec![
249                    "permission_prompt".to_string(),
250                    "idle_prompt".to_string(),
251                    "auth_success".to_string(),
252                    "elicitation_dialog".to_string(),
253                    "elicitation_complete".to_string(),
254                    "elicitation_response".to_string(),
255                ],
256            }),
257        },
258    );
259
260    metadata.insert(
261        HookEvent::UserPromptSubmit,
262        HookEventMetadata {
263            summary: "When the user submits a prompt".to_string(),
264            description: "Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only".to_string(),
265            matcher_metadata: None,
266        },
267    );
268
269    metadata.insert(
270        HookEvent::SessionStart,
271        HookEventMetadata {
272            summary: "When a new session is started".to_string(),
273            description: "Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only".to_string(),
274            matcher_metadata: Some(MatcherMetadata {
275                field_to_match: "source".to_string(),
276                values: vec![
277                    "startup".to_string(),
278                    "resume".to_string(),
279                    "clear".to_string(),
280                    "compact".to_string(),
281                ],
282            }),
283        },
284    );
285
286    metadata.insert(
287        HookEvent::Stop,
288        HookEventMetadata {
289            summary: "Right before Claude concludes its response".to_string(),
290            description: "Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only".to_string(),
291            matcher_metadata: None,
292        },
293    );
294
295    metadata.insert(
296        HookEvent::StopFailure,
297        HookEventMetadata {
298            summary: "When the turn ends due to an API error".to_string(),
299            description: "Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget — hook output and exit codes are ignored.".to_string(),
300            matcher_metadata: Some(MatcherMetadata {
301                field_to_match: "error".to_string(),
302                values: vec![
303                    "rate_limit".to_string(),
304                    "authentication_failed".to_string(),
305                    "billing_error".to_string(),
306                    "invalid_request".to_string(),
307                    "server_error".to_string(),
308                    "max_output_tokens".to_string(),
309                    "unknown".to_string(),
310                ],
311            }),
312        },
313    );
314
315    metadata.insert(
316        HookEvent::SubagentStart,
317        HookEventMetadata {
318            summary: "When a subagent (Agent tool call) is started".to_string(),
319            description: "Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only".to_string(),
320            matcher_metadata: Some(MatcherMetadata {
321                field_to_match: "agent_type".to_string(),
322                values: Vec::new(),
323            }),
324        },
325    );
326
327    metadata.insert(
328        HookEvent::SubagentStop,
329        HookEventMetadata {
330            summary: "Right before a subagent (Agent tool call) concludes its response".to_string(),
331            description: "Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only".to_string(),
332            matcher_metadata: Some(MatcherMetadata {
333                field_to_match: "agent_type".to_string(),
334                values: Vec::new(),
335            }),
336        },
337    );
338
339    metadata.insert(
340        HookEvent::PreCompact,
341        HookEventMetadata {
342            summary: "Before conversation compaction".to_string(),
343            description: "Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction".to_string(),
344            matcher_metadata: Some(MatcherMetadata {
345                field_to_match: "trigger".to_string(),
346                values: vec!["manual".to_string(), "auto".to_string()],
347            }),
348        },
349    );
350
351    metadata.insert(
352        HookEvent::PostCompact,
353        HookEventMetadata {
354            summary: "After conversation compaction".to_string(),
355            description: "Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only".to_string(),
356            matcher_metadata: Some(MatcherMetadata {
357                field_to_match: "trigger".to_string(),
358                values: vec!["manual".to_string(), "auto".to_string()],
359            }),
360        },
361    );
362
363    metadata.insert(
364        HookEvent::SessionEnd,
365        HookEventMetadata {
366            summary: "When a session is ending".to_string(),
367            description: "Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only".to_string(),
368            matcher_metadata: Some(MatcherMetadata {
369                field_to_match: "reason".to_string(),
370                values: vec![
371                    "clear".to_string(),
372                    "logout".to_string(),
373                    "prompt_input_exit".to_string(),
374                    "other".to_string(),
375                ],
376            }),
377        },
378    );
379
380    metadata.insert(
381        HookEvent::PermissionRequest,
382        HookEventMetadata {
383            summary: "When a permission dialog is displayed".to_string(),
384            description: "Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only".to_string(),
385            matcher_metadata: Some(MatcherMetadata {
386                field_to_match: "tool_name".to_string(),
387                values: tool_names.to_vec(),
388            }),
389        },
390    );
391
392    metadata.insert(
393        HookEvent::Setup,
394        HookEventMetadata {
395            summary: "Repo setup hooks for init and maintenance".to_string(),
396            description: "Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only".to_string(),
397            matcher_metadata: Some(MatcherMetadata {
398                field_to_match: "trigger".to_string(),
399                values: vec!["init".to_string(), "maintenance".to_string()],
400            }),
401        },
402    );
403
404    metadata.insert(
405        HookEvent::TeammateIdle,
406        HookEventMetadata {
407            summary: "When a teammate is about to go idle".to_string(),
408            description: "Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only".to_string(),
409            matcher_metadata: None,
410        },
411    );
412
413    metadata.insert(
414        HookEvent::TaskCreated,
415        HookEventMetadata {
416            summary: "When a task is being created".to_string(),
417            description: "Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only".to_string(),
418            matcher_metadata: None,
419        },
420    );
421
422    metadata.insert(
423        HookEvent::TaskCompleted,
424        HookEventMetadata {
425            summary: "When a task is being marked as completed".to_string(),
426            description: "Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only".to_string(),
427            matcher_metadata: None,
428        },
429    );
430
431    metadata.insert(
432        HookEvent::Elicitation,
433        HookEventMetadata {
434            summary: "When an MCP server requests user input (elicitation)".to_string(),
435            description: "Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only".to_string(),
436            matcher_metadata: Some(MatcherMetadata {
437                field_to_match: "mcp_server_name".to_string(),
438                values: Vec::new(),
439            }),
440        },
441    );
442
443    metadata.insert(
444        HookEvent::ElicitationResult,
445        HookEventMetadata {
446            summary: "After a user responds to an MCP elicitation".to_string(),
447            description: "Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only".to_string(),
448            matcher_metadata: Some(MatcherMetadata {
449                field_to_match: "mcp_server_name".to_string(),
450                values: Vec::new(),
451            }),
452        },
453    );
454
455    metadata.insert(
456        HookEvent::ConfigChange,
457        HookEventMetadata {
458            summary: "When configuration files change during a session".to_string(),
459            description: "Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only".to_string(),
460            matcher_metadata: Some(MatcherMetadata {
461                field_to_match: "source".to_string(),
462                values: vec![
463                    "user_settings".to_string(),
464                    "project_settings".to_string(),
465                    "local_settings".to_string(),
466                    "policy_settings".to_string(),
467                    "skills".to_string(),
468                ],
469            }),
470        },
471    );
472
473    metadata.insert(
474        HookEvent::InstructionsLoaded,
475        HookEventMetadata {
476            summary: "When an instruction file (CLAUDE.md or rule) is loaded".to_string(),
477            description: "Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional — the paths: frontmatter patterns that matched), trigger_file_path (optional — the file Claude touched that caused the load), and parent_file_path (optional — the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.".to_string(),
478            matcher_metadata: Some(MatcherMetadata {
479                field_to_match: "load_reason".to_string(),
480                values: vec![
481                    "session_start".to_string(),
482                    "nested_traversal".to_string(),
483                    "path_glob_match".to_string(),
484                    "include".to_string(),
485                    "compact".to_string(),
486                ],
487            }),
488        },
489    );
490
491    metadata.insert(
492        HookEvent::WorktreeCreate,
493        HookEventMetadata {
494            summary: "Create an isolated worktree for VCS-agnostic isolation".to_string(),
495            description: "Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed".to_string(),
496            matcher_metadata: None,
497        },
498    );
499
500    metadata.insert(
501        HookEvent::WorktreeRemove,
502        HookEventMetadata {
503            summary: "Remove a previously created worktree".to_string(),
504            description: "Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only".to_string(),
505            matcher_metadata: None,
506        },
507    );
508
509    metadata.insert(
510        HookEvent::CwdChanged,
511        HookEventMetadata {
512            summary: "After the working directory changes".to_string(),
513            description: "Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set — write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only".to_string(),
514            matcher_metadata: None,
515        },
516    );
517
518    metadata.insert(
519        HookEvent::FileChanged,
520        HookEventMetadata {
521            summary: "When a watched file changes".to_string(),
522            description: "Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set — write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. \".envrc|.env\").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only".to_string(),
523            matcher_metadata: None,
524        },
525    );
526
527    metadata
528}
529
530/// Group hooks by event and matcher
531pub fn group_hooks_by_event_and_matcher(
532    tool_names: &[String],
533) -> HashMap<HookEvent, HashMap<String, Vec<IndividualHookConfig>>> {
534    let mut grouped: HashMap<HookEvent, HashMap<String, Vec<IndividualHookConfig>>> =
535        HashMap::new();
536
537    // Initialize all events with empty maps
538    for event in HOOK_EVENTS {
539        grouped.insert(event.clone(), HashMap::new());
540    }
541
542    let metadata = get_hook_event_metadata(tool_names);
543
544    // In a real implementation, this would gather hooks from:
545    // 1. Settings files (user, project, local)
546    // 2. Registered hooks (e.g., plugin hooks)
547    // 3. Session hooks
548
549    grouped
550}
551
552/// Sort matchers by priority for a specific event
553pub fn sort_matchers_by_priority(
554    matchers: &[String],
555    _hooks_by_event_and_matcher: &HashMap<HookEvent, HashMap<String, Vec<IndividualHookConfig>>>,
556    _event: &HookEvent,
557) -> Vec<String> {
558    // In the TS version, this sorts by source priority:
559    // userSettings > projectSettings > localSettings > pluginHook
560    // For now, just return sorted alphabetically
561    let mut sorted = matchers.to_vec();
562    sorted.sort();
563    sorted
564}
565
566/// Get hooks for a specific event and matcher
567pub fn get_hooks_for_matcher(
568    hooks_by_event_and_matcher: &HashMap<HookEvent, HashMap<String, Vec<IndividualHookConfig>>>,
569    event: &HookEvent,
570    matcher: Option<&str>,
571) -> Vec<IndividualHookConfig> {
572    let matcher_key = matcher.unwrap_or("");
573    hooks_by_event_and_matcher
574        .get(event)
575        .and_then(|event_map| event_map.get(matcher_key))
576        .cloned()
577        .unwrap_or_default()
578}
579
580/// Get metadata for a specific event's matcher
581pub fn get_matcher_metadata(event: &HookEvent, tool_names: &[String]) -> Option<MatcherMetadata> {
582    let metadata = get_hook_event_metadata(tool_names);
583    metadata.get(event).and_then(|m| m.matcher_metadata.clone())
584}
585
586/// Hook source description display string
587pub fn hook_source_description_display_string(source: &HookSource) -> String {
588    match source {
589        HookSource::UserSettings => "User settings (~/.claude/settings.json)".to_string(),
590        HookSource::ProjectSettings => "Project settings (.claude/settings.json)".to_string(),
591        HookSource::LocalSettings => "Local settings (.claude/settings.local.json)".to_string(),
592        HookSource::PluginHook => "Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)".to_string(),
593        HookSource::SessionHook => "Session hooks (in-memory, temporary)".to_string(),
594        HookSource::BuiltinHook => {
595            "Built-in hook (registered internally by Claude Code)".to_string()
596        }
597    }
598}
599
600/// Hook source header display string
601pub fn hook_source_header_display_string(source: &HookSource) -> String {
602    match source {
603        HookSource::UserSettings => "User Settings".to_string(),
604        HookSource::ProjectSettings => "Project Settings".to_string(),
605        HookSource::LocalSettings => "Local Settings".to_string(),
606        HookSource::PluginHook => "Plugin Hooks".to_string(),
607        HookSource::SessionHook => "Session Hooks".to_string(),
608        HookSource::BuiltinHook => "Built-in Hooks".to_string(),
609    }
610}
611
612/// Hook source inline display string
613pub fn hook_source_inline_display_string(source: &HookSource) -> String {
614    match source {
615        HookSource::UserSettings => "User".to_string(),
616        HookSource::ProjectSettings => "Project".to_string(),
617        HookSource::LocalSettings => "Local".to_string(),
618        HookSource::PluginHook => "Plugin".to_string(),
619        HookSource::SessionHook => "Session".to_string(),
620        HookSource::BuiltinHook => "Built-in".to_string(),
621    }
622}
623
624/// Check if two hooks are equal (comparing only command/prompt content, not timeout)
625pub fn is_hook_equal(a: &HookCommand, b: &HookCommand) -> bool {
626    // We only compare command/prompt content, not timeout
627    // `if` is part of identity: same command with different `if` conditions
628    // are distinct hooks
629    match (a, b) {
630        (
631            HookCommand::Command {
632                command: cmd_a,
633                shell: shell_a,
634                if_condition: if_a,
635                ..
636            },
637            HookCommand::Command {
638                command: cmd_b,
639                shell: shell_b,
640                if_condition: if_b,
641                ..
642            },
643        ) => {
644            cmd_a == cmd_b
645                && (shell_a.clone().unwrap_or_else(|| "bash".to_string())
646                    == shell_b.clone().unwrap_or_else(|| "bash".to_string()))
647                && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default())
648        }
649        (
650            HookCommand::Prompt {
651                prompt: p_a,
652                if_condition: if_a,
653                ..
654            },
655            HookCommand::Prompt {
656                prompt: p_b,
657                if_condition: if_b,
658                ..
659            },
660        ) => p_a == p_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
661        (
662            HookCommand::Agent {
663                prompt: p_a,
664                if_condition: if_a,
665                ..
666            },
667            HookCommand::Agent {
668                prompt: p_b,
669                if_condition: if_b,
670                ..
671            },
672        ) => p_a == p_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
673        (
674            HookCommand::Http {
675                url: u_a,
676                if_condition: if_a,
677                ..
678            },
679            HookCommand::Http {
680                url: u_b,
681                if_condition: if_b,
682                ..
683            },
684        ) => u_a == u_b && (if_a.clone().unwrap_or_default() == if_b.clone().unwrap_or_default()),
685        _ => false,
686    }
687}