/** * HooksEditor component for managing Claude Code hooks configuration */ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Plus, Trash2, AlertTriangle, Code2, Terminal, FileText, ChevronRight, ChevronDown, Clock, Zap, Shield, PlayCircle, Info, Save, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { HooksManager } from '@/lib/hooksManager'; import { api } from '@/lib/api'; import { useTranslation } from '@/hooks/useTranslation'; import { HooksConfiguration, HookEvent, HookMatcher, HookCommand, HookTemplate, COMMON_TOOL_MATCHERS, HOOK_TEMPLATES, } from '@/types/hooks'; interface HooksEditorProps { projectPath?: string; scope: 'project' | 'local' | 'user'; readOnly?: boolean; className?: string; onChange?: (hasChanges: boolean, getHooks: () => HooksConfiguration) => void; hideActions?: boolean; } interface EditableHookCommand extends HookCommand { id: string; } interface EditableHookMatcher extends Omit { id: string; hooks: EditableHookCommand[]; expanded?: boolean; } const EVENT_INFO: Record = { PreToolUse: { label: 'Pre Tool Use', description: 'Runs before tool calls, can block and provide feedback', icon: }, PostToolUse: { label: 'Post Tool Use', description: 'Runs after successful tool completion', icon: }, Notification: { label: 'Notification', description: 'Customizes notifications when Claude needs attention', icon: }, Stop: { label: 'Stop', description: 'Runs when Claude finishes responding', icon: }, SubagentStop: { label: 'Subagent Stop', description: 'Runs when a Claude subagent (Task) finishes', icon: } }; export const HooksEditor: React.FC = ({ projectPath, scope, readOnly = false, className, onChange, hideActions = false }) => { const { t } = useTranslation(); const [selectedEvent, setSelectedEvent] = useState('PreToolUse'); const [showTemplateDialog, setShowTemplateDialog] = useState(false); const [validationErrors, setValidationErrors] = useState([]); const [validationWarnings, setValidationWarnings] = useState([]); const isInitialMount = React.useRef(true); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [hooks, setHooks] = useState({}); // Events with matchers (tool-related) const matcherEvents = ['PreToolUse', 'PostToolUse'] as const; // Events without matchers (non-tool-related) const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const; // Convert hooks to editable format with IDs const [editableHooks, setEditableHooks] = useState<{ PreToolUse: EditableHookMatcher[]; PostToolUse: EditableHookMatcher[]; Notification: EditableHookCommand[]; Stop: EditableHookCommand[]; SubagentStop: EditableHookCommand[]; }>(() => { const result = { PreToolUse: [], PostToolUse: [], Notification: [], Stop: [], SubagentStop: [] } as any; // Initialize matcher events matcherEvents.forEach(event => { const matchers = hooks?.[event] as HookMatcher[] | undefined; if (matchers && Array.isArray(matchers)) { result[event] = matchers.map(matcher => ({ ...matcher, id: HooksManager.generateId(), expanded: false, hooks: (matcher.hooks || []).map(hook => ({ ...hook, id: HooksManager.generateId() })) })); } }); // Initialize direct events directEvents.forEach(event => { const commands = hooks?.[event] as HookCommand[] | undefined; if (commands && Array.isArray(commands)) { result[event] = commands.map(hook => ({ ...hook, id: HooksManager.generateId() })); } }); return result; }); // Load hooks when projectPath or scope changes useEffect(() => { // For user scope, we don't need a projectPath if (scope === 'user' || projectPath) { setIsLoading(true); setLoadError(null); api.getHooksConfig(scope, projectPath) .then((config) => { setHooks(config || {}); setHasUnsavedChanges(false); }) .catch((err) => { console.error("Failed to load hooks configuration:", err); setLoadError(err instanceof Error ? err.message : "Failed to load hooks configuration"); setHooks({}); }) .finally(() => { setIsLoading(false); }); } else { // No projectPath for project/local scopes setHooks({}); } }, [projectPath, scope]); // Reset initial mount flag when hooks prop changes useEffect(() => { isInitialMount.current = true; setHasUnsavedChanges(false); // Reset unsaved changes when hooks prop changes // Reinitialize editable hooks when hooks prop changes const result = { PreToolUse: [], PostToolUse: [], Notification: [], Stop: [], SubagentStop: [] } as any; // Initialize matcher events matcherEvents.forEach(event => { const matchers = hooks?.[event] as HookMatcher[] | undefined; if (matchers && Array.isArray(matchers)) { result[event] = matchers.map(matcher => ({ ...matcher, id: HooksManager.generateId(), expanded: false, hooks: (matcher.hooks || []).map(hook => ({ ...hook, id: HooksManager.generateId() })) })); } }); // Initialize direct events directEvents.forEach(event => { const commands = hooks?.[event] as HookCommand[] | undefined; if (commands && Array.isArray(commands)) { result[event] = commands.map(hook => ({ ...hook, id: HooksManager.generateId() })); } }); setEditableHooks(result); }, [hooks]); // Track changes when editable hooks change (but don't save automatically) useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; return; } setHasUnsavedChanges(true); }, [editableHooks]); // Notify parent of changes useEffect(() => { if (onChange) { const getHooks = () => { const newHooks: HooksConfiguration = {}; // Handle matcher events matcherEvents.forEach(event => { const matchers = editableHooks[event]; if (matchers.length > 0) { newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({ ...matcher, hooks: matcher.hooks.map(({ id, ...hook }) => hook) })); } }); // Handle direct events directEvents.forEach(event => { const commands = editableHooks[event]; if (commands.length > 0) { newHooks[event] = commands.map(({ id, ...hook }) => hook); } }); return newHooks; }; onChange(hasUnsavedChanges, getHooks); } }, [hasUnsavedChanges, editableHooks, onChange]); // Save function to be called explicitly const handleSave = async () => { if (scope !== 'user' && !projectPath) return; setIsSaving(true); const newHooks: HooksConfiguration = {}; // Handle matcher events matcherEvents.forEach(event => { const matchers = editableHooks[event]; if (matchers.length > 0) { newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({ ...matcher, hooks: matcher.hooks.map(({ id, ...hook }) => hook) })); } }); // Handle direct events directEvents.forEach(event => { const commands = editableHooks[event]; if (commands.length > 0) { newHooks[event] = commands.map(({ id, ...hook }) => hook); } }); try { await api.updateHooksConfig(scope, newHooks, projectPath); setHooks(newHooks); setHasUnsavedChanges(false); } catch (error) { console.error('Failed to save hooks:', error); setLoadError(error instanceof Error ? error.message : 'Failed to save hooks'); } finally { setIsSaving(false); } }; const addMatcher = (event: HookEvent) => { // Only for events with matchers if (!matcherEvents.includes(event as any)) return; const newMatcher: EditableHookMatcher = { id: HooksManager.generateId(), matcher: '', hooks: [], expanded: true }; setEditableHooks(prev => ({ ...prev, [event]: [...(prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher] })); }; const addDirectCommand = (event: HookEvent) => { // Only for events without matchers if (!directEvents.includes(event as any)) return; const newCommand: EditableHookCommand = { id: HooksManager.generateId(), type: 'command', command: '' }; setEditableHooks(prev => ({ ...prev, [event]: [...(prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), newCommand] })); }; const updateMatcher = (event: HookEvent, matcherId: string, updates: Partial) => { if (!matcherEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => matcher.id === matcherId ? { ...matcher, ...updates } : matcher ) })); }; const removeMatcher = (event: HookEvent, matcherId: string) => { if (!matcherEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).filter(matcher => matcher.id !== matcherId) })); }; const updateDirectCommand = (event: HookEvent, commandId: string, updates: Partial) => { if (!directEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).map(cmd => cmd.id === commandId ? { ...cmd, ...updates } : cmd ) })); }; const removeDirectCommand = (event: HookEvent, commandId: string) => { if (!directEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).filter(cmd => cmd.id !== commandId) })); }; const applyTemplate = (template: HookTemplate) => { if (matcherEvents.includes(template.event as any)) { // For events with matchers const newMatcher: EditableHookMatcher = { id: HooksManager.generateId(), matcher: template.matcher, hooks: template.commands.map(cmd => ({ id: HooksManager.generateId(), type: 'command' as const, command: cmd })), expanded: true }; setEditableHooks(prev => ({ ...prev, [template.event]: [...(prev[template.event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher] })); } else { // For direct events const newCommands: EditableHookCommand[] = template.commands.map(cmd => ({ id: HooksManager.generateId(), type: 'command' as const, command: cmd })); setEditableHooks(prev => ({ ...prev, [template.event]: [...(prev[template.event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), ...newCommands] })); } setSelectedEvent(template.event); setShowTemplateDialog(false); }; const validateHooks = async () => { if (!hooks) { setValidationErrors([]); setValidationWarnings([]); return; } const result = await HooksManager.validateConfig(hooks); setValidationErrors(result.errors.map(e => e.message)); setValidationWarnings(result.warnings.map(w => `${w.message} in command: ${(w.command || '').substring(0, 50)}...`)); }; useEffect(() => { validateHooks(); }, [hooks]); const addCommand = (event: HookEvent, matcherId: string) => { if (!matcherEvents.includes(event as any)) return; const newCommand: EditableHookCommand = { id: HooksManager.generateId(), type: 'command', command: '' }; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => matcher.id === matcherId ? { ...matcher, hooks: [...matcher.hooks, newCommand] } : matcher ) })); }; const updateCommand = ( event: HookEvent, matcherId: string, commandId: string, updates: Partial ) => { if (!matcherEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => matcher.id === matcherId ? { ...matcher, hooks: matcher.hooks.map(cmd => cmd.id === commandId ? { ...cmd, ...updates } : cmd ) } : matcher ) })); }; const removeCommand = (event: HookEvent, matcherId: string, commandId: string) => { if (!matcherEvents.includes(event as any)) return; setEditableHooks(prev => ({ ...prev, [event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher => matcher.id === matcherId ? { ...matcher, hooks: matcher.hooks.filter(cmd => cmd.id !== commandId) } : matcher ) })); }; const renderMatcher = (event: HookEvent, matcher: EditableHookMatcher) => (

Tool name pattern (regex supported). Leave empty to match all tools.

updateMatcher(event, matcher.id, { matcher: e.target.value })} disabled={readOnly} className="flex-1" /> {!readOnly && ( )}
{matcher.expanded && (
{!readOnly && ( )}
{matcher.hooks.length === 0 ? (

No commands added yet

) : (
{matcher.hooks.map((hook) => (