mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<string, RawHookEntry[]> };
|
||||
project?: { path?: string | null; hooks?: Record<string, RawHookEntry[]> };
|
||||
}
|
||||
): 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<HooksResponse> {
|
||||
const url = projectPath ? `/api/hooks?path=${encodeURIComponent(projectPath)}` : '/api/hooks';
|
||||
const data = await fetchApi<{ hooks?: Hook[] }>(url);
|
||||
const data = await fetchApi<Record<string, unknown>>(url);
|
||||
return {
|
||||
hooks: data.hooks ?? [],
|
||||
hooks: parseHooksConfig(data as Parameters<typeof parseHooksConfig>[0]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3515,12 +3594,35 @@ export async function deleteHook(hookName: string): Promise<void> {
|
||||
|
||||
/**
|
||||
* 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<Hook> {
|
||||
return fetchApi<Hook>('/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<string, unknown> = {
|
||||
_templateId: templateData.id,
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: fullCommand,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (templateData.matcher) {
|
||||
hookData.matcher = templateData.matcher;
|
||||
}
|
||||
|
||||
return saveHook('project', trigger, hookData);
|
||||
}
|
||||
|
||||
// ========== Rules API ==========
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<HookQuickTemplates
|
||||
onInstallTemplate={handleInstallTemplate}
|
||||
installedTemplates={installedTemplates}
|
||||
isLoading={installMutation.isPending}
|
||||
isLoading={!!installingTemplateId}
|
||||
installingTemplateId={installingTemplateId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user