feat: implement backend API for installing hook templates in HookManager and HookWizard components

This commit is contained in:
catlog22
2026-03-03 10:26:32 +08:00
parent 25f766ef26
commit 9cfd5c05fc
3 changed files with 86 additions and 132 deletions

View File

@@ -248,7 +248,8 @@ const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: strin
notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' }, notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' },
indexing: { icon: Database, color: 'text-purple-500', bg: 'bg-purple-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' }, automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' },
utility: { icon: Settings, color: 'text-gray-500', bg: 'bg-gray-500/10' } utility: { icon: Settings, color: 'text-gray-500', bg: 'bg-gray-500/10' },
protection: { icon: Shield, color: 'text-red-500', bg: 'bg-red-500/10' },
}; };
// ========== Template Icons ========== // ========== Template Icons ==========

View File

@@ -74,81 +74,27 @@ interface SkillContextConfig {
skillConfigs: Array<{ skill: string; keywords: string }>; skillConfigs: Array<{ skill: string; keywords: string }>;
} }
// ========== Hook Templates (from old hook-manager.js) ========== // ========== Hook Templates ==========
// Templates are now defined in backend: ccw/src/core/hooks/hook-templates.ts
// All templates use `ccw hook template exec <id> --stdin` format
// This avoids Windows Git Bash quote handling issues
interface HookTemplate { interface HookTemplate {
event: string; event: string;
matcher: string; matcher: string;
command: string;
args: string[];
timeout?: number; timeout?: number;
} }
// NOTE: Hook input is received via stdin (not environment variable) // Template IDs that map to backend templates
// Node.js: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); const TEMPLATE_IDS = {
// Bash: INPUT=$(cat) 'memory-update-queue': 'memory-auto-compress',
const HOOK_TEMPLATES: Record<string, HookTemplate> = { 'danger-bash-confirm': 'danger-bash-confirm',
'memory-update-queue': { 'danger-file-protection': 'danger-file-protection',
event: 'Stop', 'danger-git-destructive': 'danger-git-destructive',
matcher: '', 'danger-network-confirm': 'danger-network-confirm',
command: 'node', 'danger-system-paths': 'danger-system-paths',
args: ['-e', "require('child_process').spawnSync(process.platform==='win32'?'cmd':'ccw',process.platform==='win32'?['/c','ccw','tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'gemini'})]:['tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'gemini'})],{stdio:'inherit'})"], 'danger-permission-change': 'danger-permission-change',
}, } as const;
'skill-context-keyword': {
event: 'UserPromptSubmit',
matcher: '',
command: 'node',
args: ['-e', "const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({prompt:p.prompt||''})],{stdio:'inherit'})"],
},
'skill-context-auto': {
event: 'UserPromptSubmit',
matcher: '',
command: 'node',
args: ['-e', "const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({mode:'auto',prompt:p.prompt||''})],{stdio:'inherit'})"],
},
'danger-bash-confirm': {
event: 'PreToolUse',
matcher: 'Bash',
command: 'bash',
args: ['-c', 'INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); DANGEROUS_PATTERNS="rm -rf|rmdir|del /|format |shutdown|reboot|kill -9|pkill|mkfs|dd if=|chmod 777|chown -R|>/dev/|wget.*\\|.*sh|curl.*\\|.*bash"; if echo "$CMD" | grep -qiE "$DANGEROUS_PATTERNS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"Potentially dangerous command detected: requires user confirmation\\"}}" && exit 0; fi; exit 0'],
timeout: 5000,
},
'danger-file-protection': {
event: 'PreToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); PROTECTED=".env|.git/|package-lock.json|yarn.lock|.credentials|secrets|id_rsa|.pem$|.key$"; if echo "$FILE" | grep -qiE "$PROTECTED"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Protected file cannot be modified: $FILE\\"}}" >&2 && exit 2; fi; exit 0'],
timeout: 5000,
},
'danger-git-destructive': {
event: 'PreToolUse',
matcher: 'Bash',
command: 'bash',
args: ['-c', 'INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); GIT_DANGEROUS="git push.*--force|git push.*-f|git reset --hard|git clean -fd|git checkout.*--force|git branch -D|git rebase.*-f"; if echo "$CMD" | grep -qiE "$GIT_DANGEROUS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"Destructive git operation detected: $CMD\\"}}" && exit 0; fi; exit 0'],
timeout: 5000,
},
'danger-network-confirm': {
event: 'PreToolUse',
matcher: 'Bash|WebFetch',
command: 'bash',
args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); if [ "$TOOL" = "WebFetch" ]; then URL=$(echo "$INPUT" | jq -r ".tool_input.url // empty"); echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"Network request to: $URL\\"}}" && exit 0; fi; CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); NET_CMDS="curl|wget|nc |netcat|ssh |scp |rsync|ftp "; if echo "$CMD" | grep -qiE "^($NET_CMDS)"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"Network command requires confirmation: $CMD\\"}}" && exit 0; fi; exit 0'],
timeout: 5000,
},
'danger-system-paths': {
event: 'PreToolUse',
matcher: 'Write|Edit|Bash',
command: 'bash',
args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); if [ "$TOOL" = "Bash" ]; then CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|/boot/|/sys/|/proc/|C:\\\\Windows|C:\\\\Program Files"; if echo "$CMD" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"System path operation requires confirmation\\"}}" && exit 0; fi; else FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|C:\\\\Windows|C:\\\\Program Files"; if echo "$FILE" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Cannot modify system file: $FILE\\"}}" >&2 && exit 2; fi; fi; exit 0'],
timeout: 5000,
},
'danger-permission-change': {
event: 'PreToolUse',
matcher: 'Bash',
command: 'bash',
args: ['-c', 'INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); PERM_CMDS="chmod|chown|chgrp|setfacl|icacls|takeown|cacls"; if echo "$CMD" | grep -qiE "^($PERM_CMDS)"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"Permission change requires confirmation: $CMD\\"}}" && exit 0; fi; exit 0'],
timeout: 5000,
},
};
// Danger protection option definitions // Danger protection option definitions
const DANGER_OPTIONS = [ const DANGER_OPTIONS = [
@@ -326,59 +272,65 @@ export function HookWizard({
try { try {
switch (wizardType) { switch (wizardType) {
case 'memory-update': { case 'memory-update': {
const selectedTool = memoryConfig.tool; // Use backend template API to install memory template
const template = HOOK_TEMPLATES['memory-update-queue']; const response = await fetch('/api/hooks/templates/install', {
const hookData = { method: 'POST',
command: template.command, headers: { 'Content-Type': 'application/json' },
args: ['-e', `require('child_process').spawnSync(process.platform==='win32'?'cmd':'ccw',process.platform==='win32'?['/c','ccw','tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'${selectedTool}'})]:['tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'${selectedTool}'})],{stdio:'inherit'})`], body: JSON.stringify({
}; templateId: 'memory-auto-compress',
const converted = convertToClaudeCodeFormat(hookData); scope,
await saveHook(scope, template.event, converted); }),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to install template');
}
break; break;
} }
case 'danger-protection': { case 'danger-protection': {
// Install each selected protection template via backend API
for (const optionId of dangerConfig.selectedOptions) { for (const optionId of dangerConfig.selectedOptions) {
const option = DANGER_OPTIONS.find(o => o.id === optionId); const option = DANGER_OPTIONS.find(o => o.id === optionId);
if (!option) continue; if (!option) continue;
const template = HOOK_TEMPLATES[option.templateId]; const templateId = TEMPLATE_IDS[option.templateId as keyof typeof TEMPLATE_IDS] || option.templateId;
if (!template) continue;
const hookData = { const response = await fetch('/api/hooks/templates/install', {
command: template.command, method: 'POST',
args: [...template.args], headers: { 'Content-Type': 'application/json' },
matcher: template.matcher, body: JSON.stringify({
timeout: template.timeout, templateId,
}; scope,
const converted = convertToClaudeCodeFormat(hookData); }),
await saveHook(scope, template.event, converted); });
const result = await response.json();
if (!result.success) {
console.warn(`Failed to install template ${templateId}:`, result.error);
}
} }
break; break;
} }
case 'skill-context': { case 'skill-context': {
if (skillConfig.mode === 'auto') { // Use ccw hook command directly for skill context
const template = HOOK_TEMPLATES['skill-context-auto']; const hookData = skillConfig.mode === 'auto'
const hookData = { ? {
command: template.command, _templateId: 'skill-context-auto',
args: [...template.args], matcher: '',
}; hooks: [{
const converted = convertToClaudeCodeFormat(hookData); type: 'command',
await saveHook(scope, template.event, converted); command: 'ccw hook keyword --stdin',
} else { }],
const validConfigs = skillConfig.skillConfigs.filter(c => c.skill && c.keywords);
if (validConfigs.length === 0) break;
const configJson = validConfigs.map(c => ({
skill: c.skill,
keywords: c.keywords.split(',').map(k => k.trim()).filter(k => k),
}));
const paramsStr = JSON.stringify({ configs: configJson });
const hookData = {
command: 'node',
args: ['-e', `const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify(Object.assign(${paramsStr},{prompt:p.user_prompt||''}))],{stdio:'inherit'})`],
};
const converted = convertToClaudeCodeFormat(hookData);
await saveHook(scope, 'UserPromptSubmit', converted);
} }
: {
_templateId: 'skill-context-keyword',
matcher: '',
hooks: [{
type: 'command',
command: 'ccw hook keyword --stdin',
}],
};
await saveHook(scope, 'UserPromptSubmit', hookData);
break; break;
} }
} }
@@ -916,34 +868,22 @@ export function HookWizard({
const getPreviewCommand = (): string => { const getPreviewCommand = (): string => {
switch (wizardType) { switch (wizardType) {
case 'memory-update': { case 'memory-update': {
const selectedTool = memoryConfig.tool; return `ccw hook template exec memory-auto-compress --stdin`;
return `node -e "require('child_process').spawnSync(process.platform==='win32'?'cmd':'ccw',process.platform==='win32'?['/c','ccw','tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'${selectedTool}'})]:['tool','exec','memory_queue',JSON.stringify({action:'add',path:process.env.CLAUDE_PROJECT_DIR,tool:'${selectedTool}'})],{stdio:'inherit'})"`;
} }
case 'danger-protection': { case 'danger-protection': {
const templates = dangerConfig.selectedOptions const templates = dangerConfig.selectedOptions
.map(id => DANGER_OPTIONS.find(o => o.id === id)) .map(id => DANGER_OPTIONS.find(o => o.id === id))
.filter(Boolean) .filter(Boolean)
.map(opt => { .map(opt => {
const tpl = HOOK_TEMPLATES[opt!.templateId]; const templateId = TEMPLATE_IDS[opt!.templateId as keyof typeof TEMPLATE_IDS] || opt!.templateId;
return tpl ? `[${tpl.event}/${tpl.matcher || '*'}] ${tpl.command} ${tpl.args[0]} ...` : ''; return `ccw hook template exec ${templateId} --stdin`;
}) });
.filter(Boolean);
return templates.length > 0 return templates.length > 0
? templates.join('\n') ? templates.join('\n')
: '# No protections selected'; : '# No protections selected';
} }
case 'skill-context': { case 'skill-context': {
if (skillConfig.mode === 'auto') { return `ccw hook keyword --stdin`;
return `node -e "const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({mode:'auto',prompt:p.user_prompt||''})],{stdio:'inherit'})"`;
}
const validConfigs = skillConfig.skillConfigs.filter(c => c.skill && c.keywords);
if (validConfigs.length === 0) return '# No SKILL configurations yet';
const configJson = validConfigs.map(c => ({
skill: c.skill,
keywords: c.keywords.split(',').map(k => k.trim()).filter(k => k),
}));
const paramsStr = JSON.stringify({ configs: configJson });
return `node -e "const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify(Object.assign(${paramsStr},{prompt:p.user_prompt||''}))],{stdio:'inherit'})"`;
} }
default: default:
return ''; return '';

View File

@@ -268,12 +268,25 @@ export function HookManagerPage() {
setInstallingTemplateId(templateId); setInstallingTemplateId(templateId);
try { try {
await installHookTemplate(template.trigger, { // Use backend API to install template
id: template.id, const response = await fetch('/api/hooks/templates/install', {
command: template.command, method: 'POST',
args: template.args ? [...template.args] : undefined, headers: { 'Content-Type': 'application/json' },
matcher: template.matcher, body: JSON.stringify({
templateId,
scope: 'project',
}),
}); });
if (!response.ok) {
throw new Error(`Failed to install template: ${response.statusText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unknown error');
}
await refetch(); await refetch();
} catch (error) { } catch (error) {
console.error('Failed to install template:', error); console.error('Failed to install template:', error);