feat(a2ui): Implement A2UI backend with question handling and WebSocket support

- Added A2UITypes for defining question structures and answers.
- Created A2UIWebSocketHandler for managing WebSocket connections and message handling.
- Developed ask-question tool for interactive user questions via A2UI.
- Introduced platformUtils for platform detection and shell command handling.
- Centralized TypeScript types in index.ts for better organization.
- Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View File

@@ -0,0 +1,194 @@
// ========================================
// Event Group Component
// ========================================
// Groups hooks by trigger event type
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
ChevronDown,
ChevronUp,
Zap,
Wrench,
CheckCircle,
StopCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { HookCard, type HookCardData, type HookTriggerType } from './HookCard';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface EventGroupProps {
eventType: HookTriggerType;
hooks: HookCardData[];
onHookToggle: (hookName: string, enabled: boolean) => void;
onHookEdit: (hook: HookCardData) => void;
onHookDelete: (hookName: string) => void;
}
// ========== Helper Functions ==========
function getEventIcon(eventType: HookTriggerType) {
switch (eventType) {
case 'UserPromptSubmit':
return Zap;
case 'PreToolUse':
return Wrench;
case 'PostToolUse':
return CheckCircle;
case 'Stop':
return StopCircle;
}
}
function getEventColor(eventType: HookTriggerType): string {
switch (eventType) {
case 'UserPromptSubmit':
return 'text-amber-500 bg-amber-500/10';
case 'PreToolUse':
return 'text-blue-500 bg-blue-500/10';
case 'PostToolUse':
return 'text-green-500 bg-green-500/10';
case 'Stop':
return 'text-red-500 bg-red-500/10';
}
}
// ========== Component ==========
export function EventGroup({
eventType,
hooks,
onHookToggle,
onHookEdit,
onHookDelete,
}: EventGroupProps) {
const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = useState(true);
const [expandedHooks, setExpandedHooks] = useState<Set<string>>(new Set());
const Icon = getEventIcon(eventType);
const iconColorClass = getEventColor(eventType);
const enabledCount = hooks.filter((h) => h.enabled).length;
const totalCount = hooks.length;
const handleToggleExpand = () => {
setIsExpanded(!isExpanded);
};
const handleToggleHookExpand = (hookName: string) => {
setExpandedHooks((prev) => {
const next = new Set(prev);
if (next.has(hookName)) {
next.delete(hookName);
} else {
next.add(hookName);
}
return next;
});
};
const handleExpandAll = () => {
setExpandedHooks(new Set(hooks.map((h) => h.name)));
};
const handleCollapseAll = () => {
setExpandedHooks(new Set());
};
return (
<Card className="overflow-hidden">
{/* Event Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors border-b border-border"
onClick={handleToggleExpand}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-lg', iconColorClass)}>
<Icon className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
{formatMessage({ id: `cliHooks.trigger.${eventType}` })}
</h3>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'cliHooks.stats.count' }, {
enabled: enabledCount,
total: totalCount
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{totalCount}
</Badge>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Hooks List */}
{isExpanded && (
<div className="p-4 space-y-3 bg-muted/10">
{totalCount === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{formatMessage({ id: 'cliHooks.empty.noHooksInEvent' })}</p>
</div>
) : (
<>
{/* Expand/Collapse All */}
{totalCount > 1 && (
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleExpandAll}
>
{formatMessage({ id: 'cliHooks.actions.expandAll' })}
</Button>
<span className="text-muted-foreground text-xs">/</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleCollapseAll}
>
{formatMessage({ id: 'cliHooks.actions.collapseAll' })}
</Button>
</div>
)}
{/* Hook Cards */}
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.name}
hook={hook}
isExpanded={expandedHooks.has(hook.name)}
onToggleExpand={() => handleToggleHookExpand(hook.name)}
onToggle={onHookToggle}
onEdit={onHookEdit}
onDelete={onHookDelete}
/>
))}
</div>
</>
)}
</div>
)}
</Card>
);
}
export default EventGroup;

View File

@@ -0,0 +1,238 @@
// ========================================
// Hook Card Component
// ========================================
// Individual hook display card with actions
import { useIntl } from 'react-intl';
import {
GitFork,
Power,
PowerOff,
Edit,
Trash2,
ChevronDown,
ChevronUp,
} 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';
// ========== Types ==========
export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
export interface HookCardData {
name: string;
description?: string;
enabled: boolean;
trigger: HookTriggerType;
matcher?: string;
command?: string;
script?: string;
}
export interface HookCardProps {
hook: HookCardData;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: (hookName: string, enabled: boolean) => void;
onEdit: (hook: HookCardData) => void;
onDelete: (hookName: string) => void;
}
// ========== Helper Functions ==========
function getTriggerIcon(trigger: HookTriggerType) {
switch (trigger) {
case 'UserPromptSubmit':
return '⚡';
case 'PreToolUse':
return '🔧';
case 'PostToolUse':
return '✅';
case 'Stop':
return '🛑';
default:
return '📌';
}
}
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
switch (trigger) {
case 'UserPromptSubmit':
return 'default';
case 'PreToolUse':
return 'secondary';
case 'PostToolUse':
return 'outline';
case 'Stop':
return 'secondary';
default:
return 'outline';
}
}
// ========== Component ==========
export function HookCard({
hook,
isExpanded,
onToggleExpand,
onToggle,
onEdit,
onDelete,
}: HookCardProps) {
const { formatMessage } = useIntl();
const handleToggle = () => {
onToggle(hook.name, !hook.enabled);
};
const handleEdit = () => {
onEdit(hook);
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'cliHooks.actions.deleteConfirm' }, { hookName: hook.name }))) {
onDelete(hook.name);
}
};
return (
<Card className={cn('overflow-hidden', !hook.enabled && 'opacity-60')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
hook.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<GitFork className={cn(
'w-4 h-4',
hook.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{hook.name}
</span>
<Badge
variant={getTriggerVariant(hook.trigger)}
className="text-xs flex-shrink-0"
>
<span className="mr-1">{getTriggerIcon(hook.trigger)}</span>
{formatMessage({ id: `cliHooks.trigger.${hook.trigger}` })}
</Badge>
<Badge
variant={hook.enabled ? 'default' : 'secondary'}
className="text-xs flex-shrink-0"
>
{hook.enabled
? formatMessage({ id: 'common.status.enabled' })
: formatMessage({ id: 'common.status.disabled' })
}
</Badge>
</div>
{hook.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{hook.description}
</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleToggle}
title={hook.enabled
? formatMessage({ id: 'cliHooks.actions.disable' })
: formatMessage({ id: 'cliHooks.actions.enable' })
}
>
{hook.enabled ? (
<Power className="w-4 h-4 text-success" />
) : (
<PowerOff className="w-4 h-4 text-muted-foreground" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleEdit}
title={formatMessage({ id: 'common.actions.edit' })}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
title={formatMessage({ id: 'common.actions.delete' })}
>
<Trash2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onToggleExpand}
title={isExpanded
? formatMessage({ id: 'cliHooks.actions.collapse' })
: formatMessage({ id: 'cliHooks.actions.expand' })
}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border bg-muted/30 p-4 space-y-3">
{hook.description && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.description' })}
</label>
<p className="text-sm text-foreground mt-1">{hook.description}</p>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.matcher' })}
</label>
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded">
{hook.matcher || formatMessage({ id: 'cliHooks.allTools' })}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.command' })}
</label>
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded break-all max-h-32 overflow-y-auto">
{hook.command || hook.script || 'N/A'}
</p>
</div>
</div>
)}
</Card>
);
}
export default HookCard;

View File

@@ -0,0 +1,289 @@
// ========================================
// Hook Form Dialog Component
// ========================================
// Dialog for creating and editing hooks
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import type { HookCardData, HookTriggerType } from './HookCard';
// ========== Types ==========
export type HookFormMode = 'create' | 'edit';
export interface HookFormData {
name: string;
description: string;
trigger: HookTriggerType;
matcher: string;
command: string;
}
export interface HookFormDialogProps {
mode: HookFormMode;
hook?: HookCardData;
open: boolean;
onClose: () => void;
onSave: (data: HookFormData) => Promise<void>;
}
// ========== Helper: Form Validation ==========
interface FormErrors {
name?: string;
trigger?: string;
command?: string;
}
function validateForm(data: HookFormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) {
errors.name = 'validation.nameRequired';
} else if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) {
errors.name = 'validation.nameInvalid';
}
if (!data.trigger) {
errors.trigger = 'validation.triggerRequired';
}
if (!data.command.trim()) {
errors.command = 'validation.commandRequired';
}
return errors;
}
// ========== Component ==========
export function HookFormDialog({
mode,
hook,
open,
onClose,
onSave,
}: HookFormDialogProps) {
const { formatMessage } = useIntl();
const [formData, setFormData] = useState<HookFormData>({
name: '',
description: '',
trigger: 'PostToolUse',
matcher: '',
command: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when dialog opens or hook changes
useEffect(() => {
if (open) {
if (mode === 'edit' && hook) {
setFormData({
name: hook.name,
description: hook.description || '',
trigger: hook.trigger,
matcher: hook.matcher || '',
command: hook.command || hook.script || '',
});
} else {
setFormData({
name: '',
description: '',
trigger: 'PostToolUse',
matcher: '',
command: '',
});
}
setErrors({});
}
}, [open, mode, hook]);
const handleFieldChange = (
field: keyof HookFormData,
value: string
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error for this field when user starts typing
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSubmit = async () => {
// Validate form
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
try {
await onSave(formData);
onClose();
} catch (error) {
console.error('Failed to save hook:', error);
} finally {
setIsSubmitting(false);
}
};
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
{ value: 'Stop', label: 'cliHooks.trigger.Stop' },
];
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{mode === 'create'
? formatMessage({ id: 'cliHooks.dialog.createTitle' })
: formatMessage({ id: 'cliHooks.dialog.editTitle' }, { hookName: hook?.name })
}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name */}
<div>
<label htmlFor="hook-name" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.name' })} *
</label>
<Input
id="hook-name"
value={formData.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.namePlaceholder' })}
className="mt-1"
error={!!errors.name}
/>
{errors.name && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.name}` })}
</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="hook-description" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.description' })}
</label>
<Textarea
id="hook-description"
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.descriptionPlaceholder' })}
className="mt-1"
rows={2}
/>
</div>
{/* Trigger */}
<div>
<label htmlFor="hook-trigger" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.trigger' })} *
</label>
<Select
value={formData.trigger}
onValueChange={(value) => handleFieldChange('trigger', value as HookTriggerType)}
>
<SelectTrigger className="mt-1" id="hook-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.label })}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.trigger && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.trigger}` })}
</p>
)}
</div>
{/* Matcher */}
<div>
<label htmlFor="hook-matcher" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.matcher' })}
</label>
<Input
id="hook-matcher"
value={formData.matcher}
onChange={(e) => handleFieldChange('matcher', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.matcherPlaceholder' })}
className="mt-1 font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.form.matcherHelp' })}
</p>
</div>
{/* Command */}
<div>
<label htmlFor="hook-command" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.command' })} *
</label>
<Textarea
id="hook-command"
value={formData.command}
onChange={(e) => handleFieldChange('command', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.commandPlaceholder' })}
className="mt-1 font-mono text-sm"
rows={4}
error={!!errors.command}
/>
{errors.command && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.command}` })}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.form.commandHelp' })}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? formatMessage({ id: 'common.actions.saving' })
: formatMessage({ id: 'common.actions.save' })
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default HookFormDialog;

View File

@@ -0,0 +1,268 @@
// ========================================
// Hook Quick Templates Component
// ========================================
// Predefined hook templates for quick installation
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Bell,
Database,
Wrench,
Check,
Zap,
} 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';
// ========== Types ==========
/**
* Template category type
*/
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
/**
* Hook template definition
*/
export interface HookTemplate {
id: string;
name: string;
description: string;
category: TemplateCategory;
trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
command: string;
args?: string[];
matcher?: string;
}
/**
* Component props
*/
export interface HookQuickTemplatesProps {
/** Callback when install button is clicked */
onInstallTemplate: (templateId: string) => Promise<void>;
/** Array of installed template IDs */
installedTemplates: string[];
/** Optional loading state */
isLoading?: boolean;
}
// ========== Hook Templates ==========
/**
* Predefined hook templates for quick installation
*/
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
{
id: 'ccw-notify',
name: 'CCW Dashboard Notify',
description: 'Send notifications to CCW dashboard when files are written',
category: 'notification',
trigger: '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'
]
},
{
id: 'codexlens-update',
name: 'CodexLens Auto-Update',
description: 'Update CodexLens index when files are written or edited',
category: 'indexing',
trigger: 'Stop',
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)'
]
},
{
id: 'git-add',
name: 'Auto Git Stage',
description: 'Automatically stage written files to git',
category: 'automation',
trigger: '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'
]
},
{
id: 'lint-check',
name: 'Auto ESLint',
description: 'Run ESLint on JavaScript/TypeScript files after write',
category: 'automation',
trigger: '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'
]
},
{
id: 'log-tool',
name: 'Tool Usage Logger',
description: 'Log all tool executions to a file for audit trail',
category: 'automation',
trigger: 'PostToolUse',
command: 'bash',
args: [
'-c',
'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'
]
}
] 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' }
};
// ========== Category Names ==========
function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<typeof useIntl>['formatMessage']): string {
const names: Record<TemplateCategory, string> = {
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
};
return names[category];
}
// ========== Main Component ==========
/**
* HookQuickTemplates - Display predefined hook templates for quick installation
*/
export function HookQuickTemplates({
onInstallTemplate,
installedTemplates,
isLoading = false
}: HookQuickTemplatesProps) {
const { formatMessage } = useIntl();
// Group templates by category
const templatesByCategory = useMemo(() => {
return HOOK_TEMPLATES.reduce((acc, template) => {
if (!acc[template.category]) {
acc[template.category] = [];
}
acc[template.category].push(template);
return acc;
}, {} as Record<TemplateCategory, HookTemplate[]>);
}, []);
// Define category order
const categoryOrder: TemplateCategory[] = ['notification', 'indexing', 'automation'];
const handleInstall = async (templateId: string) => {
await onInstallTemplate(templateId);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'cliHooks.templates.title' })}
</h2>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.templates.description' })}
</p>
</div>
</div>
{/* Categories */}
{categoryOrder.map((category) => {
const templates = templatesByCategory[category];
if (!templates || templates.length === 0) return null;
const { icon: CategoryIcon, color } = CATEGORY_ICONS[category];
return (
<div key={category} className="space-y-3">
{/* Category Header */}
<div className="flex items-center gap-2">
<CategoryIcon className={cn('w-4 h-4', color)} />
<h3 className="text-sm font-medium text-foreground">
{getCategoryName(category, formatMessage)}
</h3>
<Badge variant="outline" className="text-xs">
{templates.length}
</Badge>
</div>
{/* Template Cards */}
<div className="grid grid-cols-1 gap-3">
{templates.map((template) => {
const isInstalled = installedTemplates.includes(template.id);
const isInstalling = isLoading && !isInstalled;
return (
<Card key={template.id} className="p-4">
<div className="flex items-start justify-between gap-4">
{/* Template Info */}
<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">
{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}
</span>
</p>
)}
</div>
{/* Install Button */}
<Button
size="sm"
variant={isInstalled ? 'outline' : 'default'}
disabled={isInstalled || isInstalling}
onClick={() => handleInstall(template.id)}
className="shrink-0"
>
{isInstalled ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'cliHooks.templates.actions.installed' })}
</>
) : (
formatMessage({ id: 'cliHooks.templates.actions.install' })
)}
</Button>
</div>
</Card>
);
})}
</div>
</div>
);
})}
</div>
);
}
export default HookQuickTemplates;

View File

@@ -0,0 +1,842 @@
// ========================================
// Hook Wizard Component
// ========================================
// Multi-step wizard for creating common hook patterns
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
ChevronLeft,
ChevronRight,
X,
Brain,
Shield,
Sparkles,
CheckCircle,
AlertTriangle,
Plus,
Trash2,
} from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { fetchSkills, type Skill, createHook } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
detect,
getShell,
getShellName,
checkCompatibility,
getPlatformName,
adjustCommandForPlatform,
DEFAULT_PLATFORM_REQUIREMENTS,
type Platform,
} from '@/utils/platformUtils';
// ========== Types ==========
/**
* Supported wizard types
*/
export type WizardType = 'memory-update' | 'danger-protection' | 'skill-context';
/**
* Wizard step number
*/
type WizardStep = 1 | 2 | 3;
/**
* Component props
*/
export interface HookWizardProps {
/** Type of wizard to launch */
wizardType: WizardType;
/** Whether the dialog is open */
open: boolean;
/** Callback when dialog is closed */
onClose: () => void;
/** Callback when wizard completes with hook configuration */
onComplete: (hookConfig: {
name: string;
description: string;
trigger: string;
matcher?: string;
command: string;
}) => Promise<void>;
}
/**
* Memory update wizard configuration
*/
interface MemoryUpdateConfig {
claudePath: string;
updateFrequency: 'session-end' | 'hourly' | 'daily';
sections: string[];
}
/**
* Danger protection wizard configuration
*/
interface DangerProtectionConfig {
keywords: string;
confirmationMessage: string;
allowBypass: boolean;
}
/**
* Skill context wizard configuration
*/
interface SkillContextConfig {
keywordSkillPairs: Array<{ keyword: string; skill: string }>;
priority: 'high' | 'medium' | 'low';
}
/**
* Wizard configuration union type
*/
type WizardConfig = MemoryUpdateConfig | DangerProtectionConfig | SkillContextConfig;
// ========== Wizard Definitions ==========
/**
* Wizard metadata for each type
*/
const WIZARD_METADATA = {
'memory-update': {
title: 'cliHooks.wizards.memoryUpdate.title',
description: 'cliHooks.wizards.memoryUpdate.description',
icon: Brain,
trigger: 'Stop' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['memory-update'],
},
'danger-protection': {
title: 'cliHooks.wizards.dangerProtection.title',
description: 'cliHooks.wizards.dangerProtection.description',
icon: Shield,
trigger: 'UserPromptSubmit' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['danger-protection'],
},
'skill-context': {
title: 'cliHooks.wizards.skillContext.title',
description: 'cliHooks.wizards.skillContext.description',
icon: Sparkles,
trigger: 'UserPromptSubmit' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['skill-context'],
},
} as const;
// ========== Helper Functions ==========
/**
* Get wizard icon component
*/
function getWizardIcon(type: WizardType) {
return WIZARD_METADATA[type].icon;
}
// ========== Main Component ==========
export function HookWizard({
wizardType,
open,
onClose,
onComplete,
}: HookWizardProps) {
const { formatMessage } = useIntl();
const [currentStep, setCurrentStep] = useState<WizardStep>(1);
const [detectedPlatform, setDetectedPlatform] = useState<Platform>('linux');
// Fetch available skills for skill-context wizard
const { data: skillsData, isLoading: skillsLoading } = useQuery({
queryKey: ['skills'],
queryFn: fetchSkills,
enabled: open && wizardType === 'skill-context',
});
// Mutation for creating hook
const createMutation = useMutation({
mutationFn: createHook,
onSuccess: () => {
onClose();
setCurrentStep(1);
},
});
// Detect platform on mount
useEffect(() => {
if (open) {
setDetectedPlatform(detect());
}
}, [open]);
// Wizard configuration state
const [memoryConfig, setMemoryConfig] = useState<MemoryUpdateConfig>({
claudePath: '.claude/CLAUDE.md',
updateFrequency: 'session-end',
sections: ['all'],
});
const [dangerConfig, setDangerConfig] = useState<DangerProtectionConfig>({
keywords: 'delete\nrm\nformat\ndrop\ntruncate\nshutdown',
confirmationMessage: 'Are you sure you want to perform this action: {action}?',
allowBypass: true,
});
const [skillConfig, setSkillConfig] = useState<SkillContextConfig>({
keywordSkillPairs: [{ keyword: '', skill: '' }],
priority: 'medium',
});
// Check platform compatibility
const wizardMetadata = WIZARD_METADATA[wizardType];
const compatibilityCheck = checkCompatibility(
wizardMetadata.platformRequirements,
detectedPlatform
);
// Handlers
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep((prev) => (prev + 1) as WizardStep);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep((prev) => (prev - 1) as WizardStep);
}
};
const handleClose = () => {
setCurrentStep(1);
onClose();
};
const handleComplete = async () => {
let hookConfig: {
name: string;
description: string;
trigger: string;
matcher?: string;
command: string;
};
const wizardName = formatMessage({ id: WIZARD_METADATA[wizardType].title });
switch (wizardType) {
case 'memory-update':
hookConfig = {
name: `memory-update-${Date.now()}`,
description: `${wizardName}: Update ${memoryConfig.claudePath} on ${memoryConfig.updateFrequency}`,
trigger: wizardMetadata.trigger,
command: buildMemoryUpdateCommand(memoryConfig, detectedPlatform),
};
break;
case 'danger-protection':
hookConfig = {
name: `danger-protection-${Date.now()}`,
description: `${wizardName}: Confirm dangerous operations`,
trigger: wizardMetadata.trigger,
matcher: buildDangerMatcher(dangerConfig),
command: buildDangerProtectionCommand(dangerConfig, detectedPlatform),
};
break;
case 'skill-context':
hookConfig = {
name: `skill-context-${Date.now()}`,
description: `${wizardName}: Load SKILL based on keywords`,
trigger: wizardMetadata.trigger,
matcher: buildSkillMatcher(skillConfig),
command: buildSkillContextCommand(skillConfig, detectedPlatform),
};
break;
default:
return;
}
await createMutation.mutateAsync(hookConfig);
};
// Step renderers
const renderStep1 = () => {
const WizardIcon = getWizardIcon(wizardType);
return (
<div className="space-y-4">
{/* Introduction */}
<div className="flex items-center gap-3 pb-4 border-b">
<div className="p-3 rounded-lg bg-primary/10">
<WizardIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].description })}
</p>
</div>
</div>
{/* Platform Detection */}
<Card className="p-4">
<div className="flex items-center gap-3">
<CheckCircle className={cn(
'w-5 h-5',
compatibilityCheck.compatible ? 'text-green-500' : 'text-destructive'
)} />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.platform.detected' })}
</p>
<p className="text-xs text-muted-foreground">
{getPlatformName(detectedPlatform)} ({getShellName(getShell(detectedPlatform))})
</p>
</div>
<Badge variant={compatibilityCheck.compatible ? 'default' : 'destructive'}>
{compatibilityCheck.compatible
? formatMessage({ id: 'cliHooks.wizards.platform.compatible' })
: formatMessage({ id: 'cliHooks.wizards.platform.incompatible' })
}
</Badge>
</div>
{/* Compatibility Issues */}
{!compatibilityCheck.compatible && compatibilityCheck.issues.length > 0 && (
<div className="mt-3 flex items-start gap-2 p-3 bg-destructive/10 rounded-lg">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityError' })}
</p>
<ul className="mt-1 space-y-1">
{compatibilityCheck.issues.map((issue, i) => (
<li key={i} className="text-xs text-destructive/80">
{issue}
</li>
))}
</ul>
</div>
</div>
)}
{/* Compatibility Warnings */}
{compatibilityCheck.warnings.length > 0 && (
<div className="mt-3 flex items-start gap-2 p-3 bg-yellow-500/10 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-600">
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityWarning' })}
</p>
<ul className="mt-1 space-y-1">
{compatibilityCheck.warnings.map((warning, i) => (
<li key={i} className="text-xs text-yellow-600/80">
{warning}
</li>
))}
</ul>
</div>
</div>
)}
</Card>
{/* Trigger Event */}
<Card className="p-4">
<p className="text-sm text-muted-foreground mb-2">
{formatMessage({ id: 'cliHooks.wizards.steps.triggerEvent' })}
</p>
<Badge variant="secondary">
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
</Badge>
</Card>
</div>
);
};
const renderStep2 = () => {
switch (wizardType) {
case 'memory-update':
return renderMemoryUpdateConfig();
case 'danger-protection':
return renderDangerProtectionConfig();
case 'skill-context':
return renderSkillContextConfig();
default:
return null;
}
};
const renderStep3 = () => {
return (
<div className="space-y-4">
<div className="text-center pb-4 border-b">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.description' })}
</p>
</div>
{/* Summary */}
<Card className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.hookType' })}
</span>
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.trigger' })}
</span>
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.platform' })}
</span>
<span className="text-sm text-foreground">
{getPlatformName(detectedPlatform)}
</span>
</div>
{/* Configuration Summary */}
{renderConfigSummary()}
</Card>
{/* Command Preview */}
<Card className="p-4">
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'cliHooks.wizards.steps.review.commandPreview' })}
</p>
<pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto">
{getPreviewCommand()}
</pre>
</Card>
</div>
);
};
// Configuration renderers
const renderMemoryUpdateConfig = () => (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
</label>
<Input
value={memoryConfig.claudePath}
onChange={(e) => setMemoryConfig({ ...memoryConfig, claudePath: e.target.value })}
placeholder=".claude/CLAUDE.md"
className="mt-1 font-mono"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
</label>
<Select
value={memoryConfig.updateFrequency}
onValueChange={(value: any) => setMemoryConfig({ ...memoryConfig, updateFrequency: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session-end">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.sessionEnd' })}
</SelectItem>
<SelectItem value="hourly">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.hourly' })}
</SelectItem>
<SelectItem value="daily">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.daily' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
const renderDangerProtectionConfig = () => (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}
</label>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywordsHelp' })}
</p>
<Textarea
value={dangerConfig.keywords}
onChange={(e) => setDangerConfig({ ...dangerConfig, keywords: e.target.value })}
placeholder="delete\nrm\nformat"
className="mt-1 font-mono text-sm"
rows={5}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.confirmationMessage' })}
</label>
<Input
value={dangerConfig.confirmationMessage}
onChange={(e) => setDangerConfig({ ...dangerConfig, confirmationMessage: e.target.value })}
placeholder="Are you sure you want to {action}?"
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allow-bypass"
checked={dangerConfig.allowBypass}
onChange={(e) => setDangerConfig({ ...dangerConfig, allowBypass: e.target.checked })}
className="rounded"
/>
<label htmlFor="allow-bypass" className="text-sm text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
</label>
</div>
</div>
);
const renderSkillContextConfig = () => {
const skills = skillsData?.skills ?? [];
const addPair = () => {
setSkillConfig({
...skillConfig,
keywordSkillPairs: [...skillConfig.keywordSkillPairs, { keyword: '', skill: '' }],
});
};
const removePair = (index: number) => {
setSkillConfig({
...skillConfig,
keywordSkillPairs: skillConfig.keywordSkillPairs.filter((_, i) => i !== index),
});
};
const updatePair = (index: number, field: 'keyword' | 'skill', value: string) => {
const newPairs = [...skillConfig.keywordSkillPairs];
newPairs[index][field] = value;
setSkillConfig({ ...skillConfig, keywordSkillPairs: newPairs });
};
return (
<div className="space-y-4">
{skillsLoading ? (
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.loadingSkills' })}
</p>
) : (
<div className="space-y-3">
{skillConfig.keywordSkillPairs.map((pair, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={pair.keyword}
onChange={(e) => updatePair(index, 'keyword', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.keywordPlaceholder' })}
className="flex-1"
/>
<Select value={pair.skill} onValueChange={(value) => updatePair(index, 'skill', value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.selectSkill' })} />
</SelectTrigger>
<SelectContent>
{skills.map((skill) => (
<SelectItem key={skill.name} value={skill.name}>
{skill.name}
</SelectItem>
))}
</SelectContent>
</Select>
{skillConfig.keywordSkillPairs.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={() => removePair(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
))}
<Button variant="outline" size="sm" onClick={addPair} className="w-full">
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliHooks.wizards.skillContext.addPair' })}
</Button>
</div>
)}
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
</label>
<Select
value={skillConfig.priority}
onValueChange={(value: any) => setSkillConfig({ ...skillConfig, priority: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityHigh' })}
</SelectItem>
<SelectItem value="medium">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityMedium' })}
</SelectItem>
<SelectItem value="low">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityLow' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};
// Config summary for review step
const renderConfigSummary = () => {
switch (wizardType) {
case 'memory-update':
return (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
</span>
<span className="font-mono">{memoryConfig.claudePath}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
</span>
<span>
{formatMessage({ id: `cliHooks.wizards.memoryUpdate.frequency.${memoryConfig.updateFrequency}` })}
</span>
</div>
</div>
);
case 'danger-protection':
return (
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}:
</span>
<div className="mt-1 flex flex-wrap gap-1">
{dangerConfig.keywords.split('\n').filter(Boolean).map((kw, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{kw.trim()}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
</span>
<span>{dangerConfig.allowBypass ? 'Yes' : 'No'}</span>
</div>
</div>
);
case 'skill-context':
return (
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.keywordMappings' })}:
</span>
<div className="mt-1 space-y-1">
{skillConfig.keywordSkillPairs
.filter((p) => p.keyword && p.skill)
.map((pair, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="outline">{pair.keyword}</Badge>
<span className="text-muted-foreground">{'->'}</span>
<Badge variant="secondary">{pair.skill}</Badge>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
</span>
<span>
{formatMessage({ id: `cliHooks.wizards.skillContext.priority${skillConfig.priority.charAt(0).toUpperCase()}${skillConfig.priority.slice(1)}` })}
</span>
</div>
</div>
);
default:
return null;
}
};
// Get command preview for review step
const getPreviewCommand = (): string => {
switch (wizardType) {
case 'memory-update':
return buildMemoryUpdateCommand(memoryConfig, detectedPlatform);
case 'danger-protection':
return buildDangerProtectionCommand(dangerConfig, detectedPlatform);
case 'skill-context':
return buildSkillContextCommand(skillConfig, detectedPlatform);
default:
return '';
}
};
// Navigation buttons
const renderNavigation = () => (
<DialogFooter className="gap-2">
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrevious} disabled={createMutation.isPending}>
<ChevronLeft className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliHooks.wizards.navigation.previous' })}
</Button>
)}
{currentStep < 3 ? (
<Button onClick={handleNext} disabled={!compatibilityCheck.compatible}>
{formatMessage({ id: 'cliHooks.wizards.navigation.next' })}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : (
<Button onClick={handleComplete} disabled={createMutation.isPending}>
{createMutation.isPending
? formatMessage({ id: 'cliHooks.wizards.navigation.creating' })
: formatMessage({ id: 'cliHooks.wizards.navigation.create' })
}
</Button>
)}
<Button variant="ghost" onClick={handleClose} disabled={createMutation.isPending}>
<X className="w-4 h-4" />
</Button>
</DialogFooter>
);
// Step indicator
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2 pb-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
currentStep === step
? 'bg-primary text-primary-foreground'
: currentStep > step
? 'bg-green-500 text-white'
: 'bg-muted text-muted-foreground'
)}
>
{currentStep > step ? <CheckCircle className="w-4 h-4" /> : step}
</div>
{step < 3 && (
<div
className={cn(
'w-8 h-0.5 mx-1',
currentStep > step ? 'bg-green-500' : 'bg-muted'
)}
/>
)}
</div>
))}
</div>
);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{formatMessage({ id: 'cliHooks.wizards.title' })}
</DialogTitle>
</DialogHeader>
{renderStepIndicator()}
<div className="min-h-[300px]">
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
</div>
{renderNavigation()}
</DialogContent>
</Dialog>
);
}
export default HookWizard;
// ========== Command Builders ==========
function buildMemoryUpdateCommand(config: MemoryUpdateConfig, platform: Platform): string {
const shellCmd = getShellCommand(getShell(platform));
const command = `echo "Updating ${config.claudePath} at ${config.updateFrequency}"`;
return JSON.stringify([...shellCmd, command]);
}
function buildDangerMatcher(config: DangerProtectionConfig): string {
const keywords = config.keywords.split('\n').filter(Boolean).join('|');
return `(${keywords})`;
}
function buildDangerProtectionCommand(config: DangerProtectionConfig, platform: Platform): string {
const shellCmd = getShellCommand(getShell(platform));
const command = `echo "Checking for dangerous operations: ${config.keywords.split('\n').filter(Boolean).join(', ')}"`;
return JSON.stringify([...shellCmd, command]);
}
function buildSkillMatcher(config: SkillContextConfig): string {
const keywords = config.keywordSkillPairs
.filter((p) => p.keyword)
.map((p) => p.keyword)
.join('|');
return `(${keywords})`;
}
function buildSkillContextCommand(config: SkillContextConfig, platform: Platform): string {
const pairs = config.keywordSkillPairs.filter((p) => p.keyword && p.skill);
const command = `echo "Loading SKILL based on keywords: ${pairs.map((p) => p.keyword).join(', ')}"`;
return JSON.stringify([...getShellCommand(getShell(platform)), command]);
}

View File

@@ -0,0 +1,19 @@
// ========================================
// Hook Components Barrel Export
// ========================================
export { HookCard } from './HookCard';
export type { HookCardProps, HookCardData, HookTriggerType } from './HookCard';
export { EventGroup } from './EventGroup';
export type { EventGroupProps } from './EventGroup';
export { HookFormDialog } from './HookFormDialog';
export type { HookFormDialogProps, HookFormMode, HookFormData } from './HookFormDialog';
export { HookQuickTemplates } from './HookQuickTemplates';
export type { HookQuickTemplatesProps, HookTemplate, TemplateCategory } from './HookQuickTemplates';
export { HOOK_TEMPLATES } from './HookQuickTemplates';
export { HookWizard } from './HookWizard';
export type { HookWizardProps, WizardType } from './HookWizard';