diff --git a/ccw/frontend/src/components/hook/EventGroup.tsx b/ccw/frontend/src/components/hook/EventGroup.tsx index 2f4d5414..3ef821a7 100644 --- a/ccw/frontend/src/components/hook/EventGroup.tsx +++ b/ccw/frontend/src/components/hook/EventGroup.tsx @@ -13,6 +13,13 @@ import { CheckCircle, StopCircle, Play, + Bell, + Rocket, + Flag, + Package, + LogOut, + XCircle, + Lock, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -44,6 +51,20 @@ function getEventIcon(eventType: HookTriggerType) { return CheckCircle; case 'Stop': return StopCircle; + case 'Notification': + return Bell; + case 'SubagentStart': + return Rocket; + case 'SubagentStop': + return Flag; + case 'PreCompact': + return Package; + case 'SessionEnd': + return LogOut; + case 'PostToolUseFailure': + return XCircle; + case 'PermissionRequest': + return Lock; default: return Play; } @@ -61,6 +82,20 @@ function getEventColor(eventType: HookTriggerType): string { return 'text-green-500 bg-green-500/10'; case 'Stop': return 'text-red-500 bg-red-500/10'; + case 'Notification': + return 'text-sky-500 bg-sky-500/10'; + case 'SubagentStart': + return 'text-indigo-500 bg-indigo-500/10'; + case 'SubagentStop': + return 'text-teal-500 bg-teal-500/10'; + case 'PreCompact': + return 'text-orange-500 bg-orange-500/10'; + case 'SessionEnd': + return 'text-pink-500 bg-pink-500/10'; + case 'PostToolUseFailure': + return 'text-rose-500 bg-rose-500/10'; + case 'PermissionRequest': + return 'text-yellow-500 bg-yellow-500/10'; default: return 'text-gray-500 bg-gray-500/10'; } diff --git a/ccw/frontend/src/components/hook/HookCard.tsx b/ccw/frontend/src/components/hook/HookCard.tsx index 56c705b7..bdecd2c6 100644 --- a/ccw/frontend/src/components/hook/HookCard.tsx +++ b/ccw/frontend/src/components/hook/HookCard.tsx @@ -20,7 +20,7 @@ import { cn } from '@/lib/utils'; // ========== Types ========== -export type HookTriggerType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; +export type HookTriggerType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop' | 'Notification' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'SessionEnd' | 'PostToolUseFailure' | 'PermissionRequest'; export interface HookCardData { name: string; @@ -55,6 +55,20 @@ function getTriggerIcon(trigger: HookTriggerType) { return '✅'; case 'Stop': return '🛑'; + case 'Notification': + return '🔔'; + case 'SubagentStart': + return '🚀'; + case 'SubagentStop': + return '🏁'; + case 'PreCompact': + return '📦'; + case 'SessionEnd': + return '👋'; + case 'PostToolUseFailure': + return '❌'; + case 'PermissionRequest': + return '🔐'; default: return '📌'; } @@ -63,15 +77,20 @@ function getTriggerIcon(trigger: HookTriggerType) { function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' { switch (trigger) { case 'SessionStart': - return 'default'; case 'UserPromptSubmit': + case 'SubagentStart': return 'default'; case 'PreToolUse': + case 'Stop': + case 'PreCompact': + case 'PermissionRequest': return 'secondary'; case 'PostToolUse': + case 'Notification': + case 'SubagentStop': + case 'SessionEnd': + case 'PostToolUseFailure': return 'outline'; - case 'Stop': - return 'secondary'; default: return 'outline'; } diff --git a/ccw/frontend/src/components/hook/HookFormDialog.tsx b/ccw/frontend/src/components/hook/HookFormDialog.tsx index 43f08982..3e3c3be0 100644 --- a/ccw/frontend/src/components/hook/HookFormDialog.tsx +++ b/ccw/frontend/src/components/hook/HookFormDialog.tsx @@ -152,6 +152,13 @@ export function HookFormDialog({ { value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' }, { value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' }, { value: 'Stop', label: 'cliHooks.trigger.Stop' }, + { value: 'Notification', label: 'cliHooks.trigger.Notification' }, + { value: 'SubagentStart', label: 'cliHooks.trigger.SubagentStart' }, + { value: 'SubagentStop', label: 'cliHooks.trigger.SubagentStop' }, + { value: 'PreCompact', label: 'cliHooks.trigger.PreCompact' }, + { value: 'SessionEnd', label: 'cliHooks.trigger.SessionEnd' }, + { value: 'PostToolUseFailure', label: 'cliHooks.trigger.PostToolUseFailure' }, + { value: 'PermissionRequest', label: 'cliHooks.trigger.PermissionRequest' }, ]; return ( diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx index 03d2259d..6e2f5d2b 100644 --- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx +++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx @@ -11,11 +11,20 @@ import { Wrench, Check, Zap, + Download, + Loader2, + Shield, + FileCode, + FileSearch, + GitBranch, + Send, + FileBarChart, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/utils'; +import type { HookTriggerType } from './HookCard'; // ========== Types ========== @@ -32,7 +41,7 @@ export interface HookTemplate { name: string; description: string; category: TemplateCategory; - trigger: 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop'; + trigger: HookTriggerType; command: string; args?: string[]; matcher?: string; @@ -48,6 +57,8 @@ export interface HookQuickTemplatesProps { installedTemplates: string[]; /** Optional loading state */ isLoading?: boolean; + /** ID of the template currently being installed */ + installingTemplateId?: string | null; } // ========== Hook Templates ========== @@ -80,15 +91,120 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ '-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){const fs=require("fs");try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}catch(e){}}' ] + }, + // --- Notification --- + { + id: 'stop-notify', + name: 'Stop Notify', + description: 'Notify dashboard when Claude finishes responding', + category: 'notification', + trigger: 'Stop', + command: 'node', + args: [ + '-e', + 'const cp=require("child_process");const payload=JSON.stringify({type:"TASK_COMPLETED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' + ] + }, + // --- Automation --- + { + id: 'auto-format-on-write', + name: 'Auto Format on Write', + description: 'Auto-format files after Claude writes or edits them', + category: 'automation', + trigger: 'PostToolUse', + matcher: 'Write|Edit', + command: 'node', + args: [ + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["prettier","--write",file],{stdio:"inherit",shell:true})}' + ] + }, + { + id: 'auto-lint-on-write', + name: 'Auto Lint on Write', + description: 'Auto-lint files after Claude writes or edits them', + category: 'automation', + trigger: 'PostToolUse', + matcher: 'Write|Edit', + command: 'node', + args: [ + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["eslint","--fix",file],{stdio:"inherit",shell:true})}' + ] + }, + { + id: 'block-sensitive-files', + name: 'Block Sensitive Files', + description: 'Block modifications to sensitive files (.env, secrets, credentials)', + category: 'automation', + trigger: 'PreToolUse', + matcher: 'Write|Edit', + command: 'node', + args: [ + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/\\.env|secret|credential|\\.key$/.test(file)){process.stderr.write("Blocked: modifying sensitive file "+file);process.exit(2)}' + ] + }, + { + id: 'git-auto-stage', + name: 'Git Auto Stage', + description: 'Auto stage all modified files when Claude finishes responding', + category: 'automation', + trigger: 'Stop', + command: 'node', + args: [ + '-e', + 'const cp=require("child_process");cp.spawnSync("git",["add","-u"],{stdio:"inherit",shell:true})' + ] + }, + // --- Indexing --- + { + id: 'post-edit-index', + name: 'Post Edit Index', + description: 'Notify indexing service when files are modified', + category: 'indexing', + trigger: 'PostToolUse', + matcher: 'Write|Edit', + command: 'node', + args: [ + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");const payload=JSON.stringify({type:"FILE_MODIFIED",file:file,project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}' + ] + }, + { + id: 'session-end-summary', + name: 'Session End Summary', + description: 'Send session summary to dashboard on session end', + category: 'indexing', + trigger: 'Stop', + command: 'node', + args: [ + '-e', + 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' + ] } ] as const; // ========== Category Icons ========== -const CATEGORY_ICONS: Record = { - notification: { icon: Bell, color: 'text-blue-500' }, - indexing: { icon: Database, color: 'text-purple-500' }, - automation: { icon: Wrench, color: 'text-orange-500' } +const CATEGORY_ICONS: Record = { + notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' }, + indexing: { icon: Database, color: 'text-purple-500', bg: 'bg-purple-500/10' }, + automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' } +}; + +// ========== Template Icons ========== + +const TEMPLATE_ICONS: Record = { + 'session-start-notify': Send, + 'session-state-watch': FileBarChart, + 'stop-notify': Bell, + 'auto-format-on-write': FileCode, + 'auto-lint-on-write': FileSearch, + 'block-sensitive-files': Shield, + 'git-auto-stage': GitBranch, + 'post-edit-index': Database, + 'session-end-summary': FileBarChart, }; // ========== Category Names ========== @@ -110,7 +226,8 @@ function getCategoryName(category: TemplateCategory, formatMessage: ReturnType - {/* Template Cards */} -
+ {/* Template Card Grid */} +
{templates.map((template) => { const isInstalled = installedTemplates.includes(template.id); - const isInstalling = isLoading && !isInstalled; + const isInstalling = installingTemplateId === template.id; + const TemplateIcon = TEMPLATE_ICONS[template.id] || Zap; + const { color: catColor, bg: catBg } = CATEGORY_ICONS[template.category]; return ( - -
- {/* Template Info */} + + {/* Card Header: Icon + Name + Install */} +
+
+ +
-
-

- {formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })} -

- +

+ {formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })} +

+
+ {formatMessage({ id: `cliHooks.trigger.${template.trigger}` })} -
-

- {formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })} -

- {template.matcher && ( -

- + {template.matcher && ( + {template.matcher} - -

- )} +
+ )} +
- - {/* Install Button */} + {/* Icon Install Button */}
+ + {/* Description */} +

+ {formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })} +

); })} diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index c1b0c2cb..f49cf7e3 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -3420,21 +3420,100 @@ export interface Hook { command?: string; trigger: string; matcher?: string; + scope?: 'global' | 'project'; + index?: number; + templateId?: string; } export interface HooksResponse { hooks: Hook[]; } +/** + * Raw hook entry as stored in settings.json + * Format: { matcher?: string, hooks: [{ type: "command", command: "..." }] } + */ +interface RawHookEntry { + matcher?: string; + _templateId?: string; + hooks?: Array<{ + type?: string; + command?: string; + prompt?: string; + timeout?: number; + async?: boolean; + }>; + // Legacy flat format support + command?: string; + args?: string[]; + script?: string; + enabled?: boolean; + description?: string; +} + +/** + * Parse raw hooks config from backend into flat Hook array + */ +function parseHooksConfig( + data: { + global?: { path?: string; hooks?: Record }; + project?: { path?: string | null; hooks?: Record }; + } +): Hook[] { + const result: Hook[] = []; + + for (const scope of ['project', 'global'] as const) { + const scopeData = data[scope]; + if (!scopeData?.hooks || typeof scopeData.hooks !== 'object') continue; + + for (const [event, entries] of Object.entries(scopeData.hooks)) { + if (!Array.isArray(entries)) continue; + + entries.forEach((entry, index) => { + // Extract command from nested hooks array (official format) + let command = ''; + if (entry.hooks && Array.isArray(entry.hooks) && entry.hooks.length > 0) { + command = entry.hooks.map(h => h.command || h.prompt || '').filter(Boolean).join(' && '); + } + // Legacy flat format fallback + if (!command && entry.command) { + command = entry.args + ? `${entry.command} ${entry.args.join(' ')}` + : entry.command; + } + if (!command && entry.script) { + command = entry.script; + } + + const name = `${scope}-${event}-${index}`; + + result.push({ + name, + description: entry.description, + enabled: entry.enabled !== false, + command, + trigger: event, + matcher: entry.matcher, + scope, + index, + templateId: entry._templateId, + }); + }); + } + } + + return result; +} + /** * Fetch all hooks for a specific workspace * @param projectPath - Optional project path to filter data by workspace */ export async function fetchHooks(projectPath?: string): Promise { const url = projectPath ? `/api/hooks?path=${encodeURIComponent(projectPath)}` : '/api/hooks'; - const data = await fetchApi<{ hooks?: Hook[] }>(url); + const data = await fetchApi>(url); return { - hooks: data.hooks ?? [], + hooks: parseHooksConfig(data as Parameters[0]), }; } @@ -3515,12 +3594,35 @@ export async function deleteHook(hookName: string): Promise { /** * Install a hook from predefined template + * Converts template data to Claude Code's settings.json format: + * { _templateId, matcher?, hooks: [{ type: "command", command: "full command string" }] } */ -export async function installHookTemplate(templateId: string): Promise { - return fetchApi('/api/hooks/install-template', { - method: 'POST', - body: JSON.stringify({ templateId }), - }); +export async function installHookTemplate( + trigger: string, + templateData: { id: string; command: string; args?: string[]; matcher?: string } +): Promise<{ success: boolean }> { + // Build full command string from command + args + const fullCommand = templateData.args + ? `${templateData.command} ${templateData.args.map(a => a.includes(' ') ? `'${a}'` : a).join(' ')}` + : templateData.command; + + // Build hookData in Claude Code's official nested format + // _templateId is ignored by Claude Code but used for installed detection + const hookData: Record = { + _templateId: templateData.id, + hooks: [ + { + type: 'command', + command: fullCommand, + } + ] + }; + + if (templateData.matcher) { + hookData.matcher = templateData.matcher; + } + + return saveHook('project', trigger, hookData); } // ========== Rules API ========== diff --git a/ccw/frontend/src/locales/en/cli-hooks.json b/ccw/frontend/src/locales/en/cli-hooks.json index 3c9a0962..96175157 100644 --- a/ccw/frontend/src/locales/en/cli-hooks.json +++ b/ccw/frontend/src/locales/en/cli-hooks.json @@ -7,7 +7,14 @@ "UserPromptSubmit": "User Prompt Submit", "PreToolUse": "Pre Tool Use", "PostToolUse": "Post Tool Use", - "Stop": "Stop" + "Stop": "Stop", + "Notification": "Notification", + "SubagentStart": "Subagent Start", + "SubagentStop": "Subagent Stop", + "PreCompact": "Pre Compact", + "SessionEnd": "Session End", + "PostToolUseFailure": "Post Tool Use Failure", + "PermissionRequest": "Permission Request" }, "form": { "name": "Hook Name", @@ -77,6 +84,34 @@ "session-state-watch": { "name": "Session State Watch", "description": "Watch for session metadata file changes (workflow-session.json)" + }, + "stop-notify": { + "name": "Stop Notify", + "description": "Notify dashboard when Claude finishes responding" + }, + "auto-format-on-write": { + "name": "Auto Format on Write", + "description": "Auto-format files after Claude writes or edits them" + }, + "auto-lint-on-write": { + "name": "Auto Lint on Write", + "description": "Auto-lint files after Claude writes or edits them" + }, + "block-sensitive-files": { + "name": "Block Sensitive Files", + "description": "Block modifications to sensitive files (.env, secrets, credentials)" + }, + "git-auto-stage": { + "name": "Git Auto Stage", + "description": "Auto stage all modified files when Claude finishes responding" + }, + "post-edit-index": { + "name": "Post Edit Index", + "description": "Notify indexing service when files are modified" + }, + "session-end-summary": { + "name": "Session End Summary", + "description": "Send session summary to dashboard on session end" } }, "actions": { diff --git a/ccw/frontend/src/locales/zh/cli-hooks.json b/ccw/frontend/src/locales/zh/cli-hooks.json index 53435710..5b9a562b 100644 --- a/ccw/frontend/src/locales/zh/cli-hooks.json +++ b/ccw/frontend/src/locales/zh/cli-hooks.json @@ -7,7 +7,14 @@ "UserPromptSubmit": "用户提交提示", "PreToolUse": "工具使用前", "PostToolUse": "工具使用后", - "Stop": "停止" + "Stop": "停止", + "Notification": "通知", + "SubagentStart": "子代理启动", + "SubagentStop": "子代理停止", + "PreCompact": "压缩前", + "SessionEnd": "会话结束", + "PostToolUseFailure": "工具使用失败后", + "PermissionRequest": "权限请求" }, "form": { "name": "钩子名称", @@ -77,6 +84,34 @@ "session-state-watch": { "name": "会话状态监控", "description": "监控会话元数据文件变更 (workflow-session.json)" + }, + "stop-notify": { + "name": "停止通知", + "description": "Claude 完成响应时通知仪表盘" + }, + "auto-format-on-write": { + "name": "写入后自动格式化", + "description": "Claude 写入或编辑文件后自动格式化" + }, + "auto-lint-on-write": { + "name": "写入后自动检查", + "description": "Claude 写入或编辑文件后自动执行 lint 检查" + }, + "block-sensitive-files": { + "name": "阻止敏感文件修改", + "description": "阻止修改敏感文件(.env、密钥、凭证)" + }, + "git-auto-stage": { + "name": "Git 自动暂存", + "description": "Claude 完成响应时自动暂存所有已修改文件" + }, + "post-edit-index": { + "name": "编辑后索引", + "description": "文件修改时通知索引服务" + }, + "session-end-summary": { + "name": "会话结束摘要", + "description": "会话结束时发送摘要到仪表盘" } }, "actions": { diff --git a/ccw/frontend/src/pages/HookManagerPage.tsx b/ccw/frontend/src/pages/HookManagerPage.tsx index a1da156e..d86a9fa4 100644 --- a/ccw/frontend/src/pages/HookManagerPage.tsx +++ b/ccw/frontend/src/pages/HookManagerPage.tsx @@ -22,7 +22,6 @@ import { ChevronDown, ChevronUp, } from 'lucide-react'; -import { useMutation } from '@tanstack/react-query'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; @@ -40,12 +39,19 @@ interface HooksByTrigger { PreToolUse: HookCardData[]; PostToolUse: HookCardData[]; Stop: HookCardData[]; + Notification: HookCardData[]; + SubagentStart: HookCardData[]; + SubagentStop: HookCardData[]; + PreCompact: HookCardData[]; + SessionEnd: HookCardData[]; + PostToolUseFailure: HookCardData[]; + PermissionRequest: HookCardData[]; } // ========== Helper Functions ========== function isHookTriggerType(value: string): value is HookTriggerType { - return ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value); + return ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SubagentStart', 'SubagentStop', 'PreCompact', 'SessionEnd', 'PostToolUseFailure', 'PermissionRequest'].includes(value); } function toHookCardData(hook: { name: string; description?: string; enabled: boolean; trigger: string; matcher?: string; command?: string; script?: string }): HookCardData | null { @@ -69,6 +75,13 @@ function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger { PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'), PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'), Stop: hooks.filter((h) => h.trigger === 'Stop'), + Notification: hooks.filter((h) => h.trigger === 'Notification'), + SubagentStart: hooks.filter((h) => h.trigger === 'SubagentStart'), + SubagentStop: hooks.filter((h) => h.trigger === 'SubagentStop'), + PreCompact: hooks.filter((h) => h.trigger === 'PreCompact'), + SessionEnd: hooks.filter((h) => h.trigger === 'SessionEnd'), + PostToolUseFailure: hooks.filter((h) => h.trigger === 'PostToolUseFailure'), + PermissionRequest: hooks.filter((h) => h.trigger === 'PermissionRequest'), }; } @@ -94,6 +107,34 @@ function getTriggerStats(hooksByTrigger: HooksByTrigger) { total: hooksByTrigger.Stop.length, enabled: hooksByTrigger.Stop.filter((h) => h.enabled).length, }, + Notification: { + total: hooksByTrigger.Notification.length, + enabled: hooksByTrigger.Notification.filter((h) => h.enabled).length, + }, + SubagentStart: { + total: hooksByTrigger.SubagentStart.length, + enabled: hooksByTrigger.SubagentStart.filter((h) => h.enabled).length, + }, + SubagentStop: { + total: hooksByTrigger.SubagentStop.length, + enabled: hooksByTrigger.SubagentStop.filter((h) => h.enabled).length, + }, + PreCompact: { + total: hooksByTrigger.PreCompact.length, + enabled: hooksByTrigger.PreCompact.filter((h) => h.enabled).length, + }, + SessionEnd: { + total: hooksByTrigger.SessionEnd.length, + enabled: hooksByTrigger.SessionEnd.filter((h) => h.enabled).length, + }, + PostToolUseFailure: { + total: hooksByTrigger.PostToolUseFailure.length, + enabled: hooksByTrigger.PostToolUseFailure.filter((h) => h.enabled).length, + }, + PermissionRequest: { + total: hooksByTrigger.PermissionRequest.length, + enabled: hooksByTrigger.PermissionRequest.filter((h) => h.enabled).length, + }, }; } @@ -214,26 +255,31 @@ export function HookManagerPage() { // Determine which templates are already installed const installedTemplates = useMemo(() => { return HOOK_TEMPLATES.filter(template => { - return hooks.some(hook => { - // Check if hook name contains template ID - return hook.name.includes(template.id) || - (hook.command && hook.command.includes(template.command)); - }); + return hooks.some(hook => hook.templateId === template.id); }).map(t => t.id); }, [hooks]); - // Mutation for installing templates - const installMutation = useMutation({ - mutationFn: async (templateId: string) => { - return await installHookTemplate(templateId); - }, - onSuccess: () => { - refetch(); - }, - }); + // Track per-template installing state + const [installingTemplateId, setInstallingTemplateId] = useState(null); const handleInstallTemplate = async (templateId: string) => { - await installMutation.mutateAsync(templateId); + const template = HOOK_TEMPLATES.find(t => t.id === templateId); + if (!template) return; + + setInstallingTemplateId(templateId); + try { + await installHookTemplate(template.trigger, { + id: template.id, + command: template.command, + args: template.args ? [...template.args] : undefined, + matcher: template.matcher, + }); + await refetch(); + } catch (error) { + console.error('Failed to install template:', error); + } finally { + setInstallingTemplateId(null); + } }; const FILTER_OPTIONS: Array<{ type: HookTriggerType | 'all'; icon: typeof Zap; label: string }> = [ @@ -376,7 +422,8 @@ export function HookManagerPage() {
)}