// Hook Manager Component // Manages Claude Code hooks configuration from settings.json // ========== Hook State ========== let hookConfig = { global: { hooks: {} }, project: { hooks: {} } }; // ========== Hook Templates ========== const HOOK_TEMPLATES = { 'ccw-notify': { event: 'PostToolUse', matcher: 'Write', command: 'bash', args: ['-c', 'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'], description: 'Notify CCW dashboard when files are written', category: 'notification' }, 'log-tool': { event: 'PostToolUse', matcher: '', command: 'bash', args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); echo "[$(date)] Tool: $TOOL, File: $FILE" >> ~/.claude/tool-usage.log'], description: 'Log all tool executions to a file', category: 'logging' }, 'lint-check': { event: 'PostToolUse', matcher: 'Write', command: 'bash', args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'], description: 'Run ESLint on JavaScript/TypeScript files after write', category: 'quality' }, 'git-add': { event: 'PostToolUse', matcher: 'Write', command: 'bash', args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'], description: 'Automatically stage written files to git', category: 'git' }, 'codexlens-update': { event: 'PostToolUse', matcher: 'Write|Edit', command: 'bash', args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'], description: 'Auto-update code index when files are written or edited', category: 'indexing' }, 'memory-update-related': { event: 'Stop', matcher: '', command: 'bash', args: ['-c', 'ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\''], description: 'Update CLAUDE.md for changed modules when session ends', category: 'memory', configurable: true, config: { tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' }, strategy: { type: 'select', options: ['related', 'single-layer'], default: 'related', label: 'Strategy' } } }, 'memory-update-periodic': { event: 'PostToolUse', matcher: 'Write|Edit', command: 'bash', args: ['-c', 'INTERVAL=300; LAST_FILE=~/.claude/.last_memory_update; NOW=$(date +%s); LAST=0; [ -f "$LAST_FILE" ] && LAST=$(cat "$LAST_FILE"); if [ $((NOW - LAST)) -ge $INTERVAL ]; then echo $NOW > "$LAST_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], description: 'Periodically update CLAUDE.md (default: 5 min interval)', category: 'memory', configurable: true, config: { tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' }, interval: { type: 'number', default: 300, min: 60, max: 3600, label: 'Interval (seconds)', step: 60 } } }, 'memory-update-count-based': { event: 'PostToolUse', matcher: 'Write|Edit', command: 'bash', args: ['-c', 'THRESHOLD=10; COUNT_FILE=~/.claude/.memory_update_count; INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -z "$FILE_PATH" ] && exit 0; COUNT=0; [ -f "$COUNT_FILE" ] && COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0); COUNT=$((COUNT + 1)); echo $COUNT > "$COUNT_FILE"; if [ $COUNT -ge $THRESHOLD ]; then echo 0 > "$COUNT_FILE"; ccw tool exec update_module_claude \'{"strategy":"related","tool":"gemini"}\' & fi'], description: 'Update CLAUDE.md when file changes reach threshold (default: 10 files)', category: 'memory', configurable: true, config: { tool: { type: 'select', options: ['gemini', 'qwen', 'codex'], default: 'gemini', label: 'CLI Tool' }, threshold: { type: 'number', default: 10, min: 3, max: 50, label: 'File count threshold', step: 1 } } }, // SKILL Context Loader templates 'skill-context-keyword': { event: 'UserPromptSubmit', matcher: '', command: 'bash', args: ['-c', 'ccw tool exec skill_context_loader --stdin'], description: 'Load SKILL context based on keyword matching in user prompt', category: 'skill', configurable: true, config: { keywords: { type: 'text', default: '', label: 'Keywords (comma-separated)', placeholder: 'react,workflow,api' }, skills: { type: 'text', default: '', label: 'SKILL Names (comma-separated)', placeholder: 'prompt-enhancer,command-guide' } } }, 'skill-context-auto': { event: 'UserPromptSubmit', matcher: '', command: 'bash', args: ['-c', 'ccw tool exec skill_context_loader --stdin --mode auto'], description: 'Auto-detect and load SKILL based on skill name in prompt', category: 'skill', configurable: false }, 'memory-file-read': { event: 'PostToolUse', matcher: 'Read|mcp__ccw-tools__read_file', command: 'ccw', args: ['memory', 'track', '--type', 'file', '--action', 'read', '--stdin'], description: 'Track file reads to build context heatmap', category: 'memory', timeout: 5000 }, 'memory-file-write': { event: 'PostToolUse', matcher: 'Write|Edit|mcp__ccw-tools__write_file|mcp__ccw-tools__edit_file', command: 'ccw', args: ['memory', 'track', '--type', 'file', '--action', 'write', '--stdin'], description: 'Track file modifications to identify core modules', category: 'memory', timeout: 5000 }, 'memory-prompt-track': { event: 'UserPromptSubmit', matcher: '', command: 'ccw', args: ['memory', 'track', '--type', 'topic', '--action', 'mention', '--stdin'], description: 'Record user prompts for pattern analysis', category: 'memory', timeout: 5000 }, // Session Context - Progressive Disclosure (session start - recent sessions) 'session-context': { event: 'UserPromptSubmit', matcher: '', command: 'bash', args: ['-c', 'curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"session-start\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'], description: 'Load recent sessions at session start (time-sorted)', category: 'context', timeout: 5000 }, // Session Context - Continuous Disclosure (intent matching on every prompt) 'session-context-continuous': { event: 'UserPromptSubmit', matcher: '', command: 'bash', args: ['-c', 'PROMPT=$(cat | jq -r ".prompt // empty"); curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"context\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\",\\"prompt\\":\\"$PROMPT\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'], description: 'Load intent-matched sessions on every prompt (similarity-based)', category: 'context', timeout: 5000 } }; // ========== Wizard Templates (Special Category) ========== const WIZARD_TEMPLATES = { 'memory-update': { name: 'Memory Update Hook', description: 'Automatically update CLAUDE.md documentation based on code changes', icon: 'brain', options: [ { id: 'on-stop', name: 'On Session End', description: 'Update documentation when Claude session ends', templateId: 'memory-update-related' }, { id: 'periodic', name: 'Periodic Update', description: 'Update documentation at regular intervals during session', templateId: 'memory-update-periodic' }, { id: 'count-based', name: 'Count-Based Update', description: 'Update documentation when file changes reach threshold', templateId: 'memory-update-count-based' } ], configFields: [ { key: 'tool', type: 'select', label: 'CLI Tool', options: ['gemini', 'qwen', 'codex'], default: 'gemini', description: 'Tool for documentation generation' }, { key: 'interval', type: 'number', label: 'Interval (seconds)', default: 300, min: 60, max: 3600, step: 60, showFor: ['periodic'], description: 'Time between updates' }, { key: 'threshold', type: 'number', label: 'File Count Threshold', default: 10, min: 3, max: 50, step: 1, showFor: ['count-based'], description: 'Number of file changes to trigger update' }, { key: 'strategy', type: 'select', label: 'Update Strategy', options: ['related', 'single-layer'], default: 'related', description: 'Related: changed modules, Single-layer: current directory' } ] }, 'skill-context': { name: 'SKILL Context Loader', description: 'Automatically load SKILL packages based on keywords in user prompts', icon: 'sparkles', options: [ { id: 'keyword', name: 'Keyword Matching', description: 'Load specific SKILLs when keywords are detected in prompt', templateId: 'skill-context-keyword' }, { id: 'auto', name: 'Auto Detection', description: 'Automatically detect and load SKILLs by name in prompt', templateId: 'skill-context-auto' } ], configFields: [], requiresSkillDiscovery: true, customRenderer: 'renderSkillContextConfig' }, 'memory-setup': { name: 'Memory Module Setup', description: 'Configure automatic context tracking', icon: 'brain', options: [ { id: 'file-read', name: 'File Read Tracker', description: 'Track file reads to build context heatmap', templateId: 'memory-file-read' }, { id: 'file-write', name: 'File Write Tracker', description: 'Track file modifications to identify core modules', templateId: 'memory-file-write' }, { id: 'prompts', name: 'Prompt Tracker', description: 'Record user prompts for pattern analysis', templateId: 'memory-prompt-track' } ], configFields: [], multiSelect: true } }; // ========== Initialization ========== function initHookManager() { // Initialize Hook navigation document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => { item.addEventListener('click', () => { setActiveNavItem(item); currentView = 'hook-manager'; currentFilter = null; currentLiteType = null; currentSessionDetailKey = null; updateContentTitle(); renderHookManager(); }); }); } // ========== Data Loading ========== async function loadHookConfig() { try { const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`); if (!response.ok) throw new Error('Failed to load hook config'); const data = await response.json(); hookConfig = data; updateHookBadge(); return data; } catch (err) { console.error('Failed to load hook config:', err); return null; } } async function loadAvailableSkills() { try { const response = await fetch('/api/skills?path=' + encodeURIComponent(projectPath)); if (!response.ok) throw new Error('Failed to load skills'); const data = await response.json(); // Combine project and user skills const projectSkills = (data.projectSkills || []).map(s => ({ name: s.name, path: s.path, scope: 'project' })); const userSkills = (data.userSkills || []).map(s => ({ name: s.name, path: s.path, scope: 'user' })); // Store in window for access by wizard window.availableSkills = [...projectSkills, ...userSkills]; return window.availableSkills; } catch (err) { console.error('Failed to load available skills:', err); window.availableSkills = []; return []; } } /** * Convert internal hook format to Claude Code format * Internal: { command, args, matcher, timeout } * Claude Code: { matcher, hooks: [{ type: "command", command: "...", timeout }] } */ function convertToClaudeCodeFormat(hookData) { // If already in correct format, return as-is if (hookData.hooks && Array.isArray(hookData.hooks)) { return hookData; } // Build command string from command + args let commandStr = hookData.command || ''; if (hookData.args && Array.isArray(hookData.args)) { // Join args, properly quoting if needed const quotedArgs = hookData.args.map(arg => { if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) { return `"${arg.replace(/"/g, '\\"')}"`; } return arg; }); commandStr = `${commandStr} ${quotedArgs.join(' ')}`.trim(); } const converted = { hooks: [{ type: 'command', command: commandStr }] }; // Add matcher if present (not needed for UserPromptSubmit, Stop, etc.) if (hookData.matcher) { converted.matcher = hookData.matcher; } // Add timeout if present (in seconds for Claude Code) if (hookData.timeout) { converted.hooks[0].timeout = Math.ceil(hookData.timeout / 1000); } // Preserve replaceIndex for updates if (hookData.replaceIndex !== undefined) { converted.replaceIndex = hookData.replaceIndex; } return converted; } async function saveHook(scope, event, hookData) { try { // Convert to Claude Code format before saving const convertedHookData = convertToClaudeCodeFormat(hookData); const response = await fetch('/api/hooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectPath: projectPath, scope: scope, event: event, hookData: convertedHookData }) }); if (!response.ok) throw new Error('Failed to save hook'); const result = await response.json(); if (result.success) { await loadHookConfig(); renderHookManager(); showRefreshToast(`Hook saved successfully`, 'success'); } return result; } catch (err) { console.error('Failed to save hook:', err); showRefreshToast(`Failed to save hook: ${err.message}`, 'error'); return null; } } async function removeHook(scope, event, hookIndex) { try { const response = await fetch('/api/hooks', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectPath: projectPath, scope: scope, event: event, hookIndex: hookIndex }) }); if (!response.ok) throw new Error('Failed to remove hook'); const result = await response.json(); if (result.success) { await loadHookConfig(); renderHookManager(); showRefreshToast(`Hook removed successfully`, 'success'); } return result; } catch (err) { console.error('Failed to remove hook:', err); showRefreshToast(`Failed to remove hook: ${err.message}`, 'error'); return null; } } // ========== Badge Update ========== function updateHookBadge() { const badge = document.getElementById('badgeHooks'); if (badge) { let totalHooks = 0; // Count global hooks if (hookConfig.global?.hooks) { for (const event of Object.keys(hookConfig.global.hooks)) { const hooks = hookConfig.global.hooks[event]; totalHooks += Array.isArray(hooks) ? hooks.length : 1; } } // Count project hooks if (hookConfig.project?.hooks) { for (const event of Object.keys(hookConfig.project.hooks)) { const hooks = hookConfig.project.hooks[event]; totalHooks += Array.isArray(hooks) ? hooks.length : 1; } } badge.textContent = totalHooks; } } // ========== Hook Modal Functions ========== let editingHookData = null; function openHookCreateModal(editData = null) { const modal = document.getElementById('hookCreateModal'); const title = document.getElementById('hookModalTitle'); if (modal) { modal.classList.remove('hidden'); editingHookData = editData; // Set title based on mode title.textContent = editData ? 'Edit Hook' : 'Create Hook'; // Clear or populate form if (editData) { document.getElementById('hookEvent').value = editData.event || ''; document.getElementById('hookMatcher').value = editData.matcher || ''; document.getElementById('hookCommand').value = editData.command || ''; document.getElementById('hookArgs').value = (editData.args || []).join('\n'); // Set scope radio const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`); if (scopeRadio) scopeRadio.checked = true; } else { document.getElementById('hookEvent').value = ''; document.getElementById('hookMatcher').value = ''; document.getElementById('hookCommand').value = ''; document.getElementById('hookArgs').value = ''; document.querySelector('input[name="hookScope"][value="project"]').checked = true; } // Focus on event select document.getElementById('hookEvent').focus(); } } function closeHookCreateModal() { const modal = document.getElementById('hookCreateModal'); if (modal) { modal.classList.add('hidden'); editingHookData = null; } } function applyHookTemplate(templateName) { const template = HOOK_TEMPLATES[templateName]; if (!template) return; document.getElementById('hookEvent').value = template.event; document.getElementById('hookMatcher').value = template.matcher; document.getElementById('hookCommand').value = template.command; document.getElementById('hookArgs').value = template.args.join('\n'); } async function submitHookCreate() { const event = document.getElementById('hookEvent').value; const matcher = document.getElementById('hookMatcher').value.trim(); const command = document.getElementById('hookCommand').value.trim(); const argsText = document.getElementById('hookArgs').value.trim(); const scope = document.querySelector('input[name="hookScope"]:checked').value; // Validate required fields if (!event) { showRefreshToast('Hook event is required', 'error'); document.getElementById('hookEvent').focus(); return; } if (!command) { showRefreshToast('Command is required', 'error'); document.getElementById('hookCommand').focus(); return; } // Parse args (one per line) const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : []; // Build hook data const hookData = { command: command }; if (args.length > 0) { hookData.args = args; } if (matcher) { hookData.matcher = matcher; } // If editing, include original index for replacement if (editingHookData && editingHookData.index !== undefined) { hookData.replaceIndex = editingHookData.index; } // Submit to API await saveHook(scope, event, hookData); closeHookCreateModal(); } // ========== Helpers ========== function getHookEventDescription(event) { const descriptions = { 'PreToolUse': 'Runs before a tool is executed', 'PostToolUse': 'Runs after a tool completes', 'Notification': 'Runs when a notification is triggered', 'Stop': 'Runs when the agent stops', 'UserPromptSubmit': 'Runs when user submits a prompt' }; return descriptions[event] || event; } function getHookEventIcon(event) { const icons = { 'PreToolUse': '⏳', 'PostToolUse': '✅', 'Notification': '🔔', 'Stop': '🛑', 'UserPromptSubmit': '💬' }; return icons[event] || '🪝'; } function getHookEventIconLucide(event) { const icons = { 'PreToolUse': '', 'PostToolUse': '', 'Notification': '', 'Stop': '', 'UserPromptSubmit': '' }; return icons[event] || ''; } // ========== Wizard Modal Functions ========== let currentWizardTemplate = null; let wizardConfig = {}; async function openHookWizardModal(wizardId) { const wizard = WIZARD_TEMPLATES[wizardId]; if (!wizard) { showRefreshToast('Wizard template not found', 'error'); return; } currentWizardTemplate = { id: wizardId, ...wizard }; wizardConfig = {}; // Set defaults wizard.configFields.forEach(field => { wizardConfig[field.key] = field.default; }); // Initialize selectedOptions for multi-select wizards if (wizard.multiSelect) { wizardConfig.selectedOptions = []; } // Ensure available skills are loaded for SKILL context wizard if (wizardId === 'skill-context' && typeof window.availableSkills === 'undefined') { await loadAvailableSkills(); } const modal = document.getElementById('hookWizardModal'); if (modal) { renderWizardModalContent(); modal.classList.remove('hidden'); } } function closeHookWizardModal() { const modal = document.getElementById('hookWizardModal'); if (modal) { modal.classList.add('hidden'); currentWizardTemplate = null; wizardConfig = {}; } } function renderWizardModalContent() { const container = document.getElementById('wizardModalContent'); if (!container || !currentWizardTemplate) return; const wizard = currentWizardTemplate; const wizardId = wizard.id; const selectedOption = wizardConfig.triggerType || wizard.options[0].id; // Get translated wizard name and description const wizardName = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdate') : wizardId === 'memory-setup' ? t('hook.wizard.memorySetup') : wizardId === 'skill-context' ? t('hook.wizard.skillContext') : wizard.name; const wizardDesc = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdateDesc') : wizardId === 'memory-setup' ? t('hook.wizard.memorySetupDesc') : wizardId === 'skill-context' ? t('hook.wizard.skillContextDesc') : wizard.description; // Helper to get translated option names const getOptionName = (optId) => { if (wizardId === 'memory-update') { if (optId === 'on-stop') return t('hook.wizard.onSessionEnd'); if (optId === 'periodic') return t('hook.wizard.periodicUpdate'); if (optId === 'count-based') return t('hook.wizard.countBasedUpdate'); } if (wizardId === 'memory-setup') { if (optId === 'file-read') return t('hook.wizard.fileReadTracker'); if (optId === 'file-write') return t('hook.wizard.fileWriteTracker'); if (optId === 'prompts') return t('hook.wizard.promptTracker'); } if (wizardId === 'skill-context') { if (optId === 'keyword') return t('hook.wizard.keywordMatching'); if (optId === 'auto') return t('hook.wizard.autoDetection'); } return wizard.options.find(o => o.id === optId)?.name || ''; }; const getOptionDesc = (optId) => { if (wizardId === 'memory-update') { if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc'); if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc'); if (optId === 'count-based') return t('hook.wizard.countBasedUpdateDesc'); } if (wizardId === 'memory-setup') { if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc'); if (optId === 'file-write') return t('hook.wizard.fileWriteTrackerDesc'); if (optId === 'prompts') return t('hook.wizard.promptTrackerDesc'); } if (wizardId === 'skill-context') { if (optId === 'keyword') return t('hook.wizard.keywordMatchingDesc'); if (optId === 'auto') return t('hook.wizard.autoDetectionDesc'); } return wizard.options.find(o => o.id === optId)?.description || ''; }; // Helper to get translated field labels const getFieldLabel = (fieldKey) => { const labels = { 'tool': t('hook.wizard.cliTool'), 'interval': t('hook.wizard.intervalSeconds'), 'threshold': t('hook.wizard.fileCountThreshold'), 'strategy': t('hook.wizard.updateStrategy') }; return labels[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.label || fieldKey; }; const getFieldDesc = (fieldKey) => { const descs = { 'tool': t('hook.wizard.toolForDocGen'), 'interval': t('hook.wizard.timeBetweenUpdates'), 'threshold': t('hook.wizard.fileCountThresholdDesc'), 'strategy': t('hook.wizard.relatedStrategy') }; return descs[fieldKey] || wizard.configFields.find(f => f.key === fieldKey)?.description || ''; }; container.innerHTML = `
${escapeHtml(wizardDesc)}
${escapeHtml(fieldDesc)}
` : ''}${escapeHtml(fieldDesc)}
` : ''}${escapeHtml(generateWizardCommand())}
' + t('hook.wizard.autoDetectionInfo') + '
' + '' + t('hook.wizard.availableSkills') + ' ' + skillBadges + '
' + '' + t('hook.wizard.noSkillsConfigured') + '
' + '' + t('hook.wizard.clickAddSkill') + '
' + '${escapeHtml(template.description || 'No description')}
${escapeHtml(template.command)}
${escapeHtml(args.join('\n'))}