feat(hooks): add 7 hook templates with full install pipeline and extended trigger types

Extend HookTriggerType from 5 to 12 official events (Notification, SubagentStart,
SubagentStop, PreCompact, SessionEnd, PostToolUseFailure, PermissionRequest).
Add templates: stop-notify, auto-format-on-write, auto-lint-on-write,
block-sensitive-files, git-auto-stage, post-edit-index, session-end-summary
across notification/automation/indexing categories. Fix install pipeline to
use correct nested settings.json format with _templateId metadata for precise
detection. Redesign templates UI as responsive card grid with per-template icons.
This commit is contained in:
catlog22
2026-02-24 00:06:48 +08:00
parent e92c6ce0b1
commit 2e32ab8f72
8 changed files with 483 additions and 68 deletions

View File

@@ -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<TemplateCategory, { icon: typeof Bell; color: string }> = {
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<TemplateCategory, { icon: typeof Bell; color: string; bg: string }> = {
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<string, typeof Bell> = {
'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<t
export function HookQuickTemplates({
onInstallTemplate,
installedTemplates,
isLoading = false
isLoading = false,
installingTemplateId = null
}: HookQuickTemplatesProps) {
const { formatMessage } = useIntl();
@@ -167,55 +284,73 @@ export function HookQuickTemplates({
</Badge>
</div>
{/* Template Cards */}
<div className="grid grid-cols-1 gap-3">
{/* Template Card Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{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 (
<Card key={template.id} className="p-4">
<div className="flex items-start justify-between gap-4">
{/* Template Info */}
<Card
key={template.id}
className={cn(
'p-4 flex flex-col gap-2 transition-colors',
isInstalled && 'opacity-70'
)}
>
{/* Card Header: Icon + Name + Install */}
<div className="flex items-start gap-3">
<div className={cn('p-2 rounded-lg shrink-0', catBg)}>
<TemplateIcon className={cn('w-4 h-4', catColor)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
</h4>
<Badge variant="secondary" className="text-xs">
<h4 className="text-sm font-medium text-foreground leading-tight">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
</h4>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: `cliHooks.trigger.${template.trigger}` })}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
</p>
{template.matcher && (
<p className="text-xs text-muted-foreground mt-1">
<span className="font-mono bg-muted px-1 rounded">
{template.matcher && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">
{template.matcher}
</span>
</p>
)}
</Badge>
)}
</div>
</div>
{/* Install Button */}
{/* Icon Install Button */}
<Button
size="sm"
variant={isInstalled ? 'outline' : 'default'}
variant="ghost"
disabled={isInstalled || isInstalling}
onClick={() => handleInstall(template.id)}
className="shrink-0"
className={cn(
'h-8 w-8 p-0 shrink-0 rounded-full',
isInstalled
? 'text-green-500 hover:text-green-500'
: 'text-primary hover:bg-primary/10'
)}
title={isInstalled
? formatMessage({ id: 'cliHooks.templates.actions.installed' })
: formatMessage({ id: 'cliHooks.templates.actions.install' })
}
>
{isInstalled ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'cliHooks.templates.actions.installed' })}
</>
{isInstalling ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isInstalled ? (
<Check className="w-4 h-4" />
) : (
formatMessage({ id: 'cliHooks.templates.actions.install' })
<Download className="w-4 h-4" />
)}
</Button>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed flex-1 pl-11">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
</p>
</Card>
);
})}