mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-03 15:43:11 +08:00
feat: implement backend API for installing hook templates in HookManager and HookWizard components
This commit is contained in:
@@ -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 ==========
|
||||||
|
|||||||
@@ -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 => ({
|
_templateId: 'skill-context-keyword',
|
||||||
skill: c.skill,
|
matcher: '',
|
||||||
keywords: c.keywords.split(',').map(k => k.trim()).filter(k => k),
|
hooks: [{
|
||||||
}));
|
type: 'command',
|
||||||
const paramsStr = JSON.stringify({ configs: configJson });
|
command: 'ccw hook keyword --stdin',
|
||||||
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'})`],
|
await saveHook(scope, 'UserPromptSubmit', hookData);
|
||||||
};
|
|
||||||
const converted = convertToClaudeCodeFormat(hookData);
|
|
||||||
await saveHook(scope, 'UserPromptSubmit', converted);
|
|
||||||
}
|
|
||||||
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 '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user