Skip to content

Commit 9ad755e

Browse files
author
catlog22
committed
feat: add comprehensive analysis report for Hook templates compliance with official standards
- Introduced a detailed report outlining compliance issues and recommendations for the `ccw/frontend` implementation of Hook templates. - Identified critical issues regarding command structure and input reading methods. - Highlighted errors related to cross-platform compatibility of Bash scripts on Windows. - Documented warnings regarding matcher formats and exit code usage. - Provided a summary of supported trigger types and outlined missing triggers. - Included a section on completed fixes and references to affected files for easier tracking.
1 parent 8799a9c commit 9ad755e

6 files changed

Lines changed: 819 additions & 59 deletions

File tree

ccw/frontend/src/components/hook/HookQuickTemplates.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export interface HookQuickTemplatesProps {
6363
}
6464

6565
// ========== Hook Templates ==========
66+
// NOTE: Hook input is received via stdin (not environment variable)
67+
// Use: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');
6668

6769
/**
6870
* Predefined hook templates for quick installation
@@ -90,7 +92,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
9092
command: 'node',
9193
args: [
9294
'-e',
93-
'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){}}'
95+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){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){}}'
9496
]
9597
},
9698
// --- Notification ---
@@ -117,7 +119,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
117119
command: 'node',
118120
args: [
119121
'-e',
120-
'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})}'
122+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");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})}'
121123
]
122124
},
123125
{
@@ -130,7 +132,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
130132
command: 'node',
131133
args: [
132134
'-e',
133-
'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})}'
135+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");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})}'
134136
]
135137
},
136138
{
@@ -143,7 +145,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
143145
command: 'node',
144146
args: [
145147
'-e',
146-
'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)}'
148+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");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)}'
147149
]
148150
},
149151
{
@@ -169,7 +171,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
169171
command: 'node',
170172
args: [
171173
'-e',
172-
'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})}'
174+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");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})}'
173175
]
174176
},
175177
{
@@ -181,7 +183,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
181183
command: 'node',
182184
args: [
183185
'-e',
184-
'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})'
186+
'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");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})'
185187
]
186188
},
187189
{
@@ -221,7 +223,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
221223
description: 'Sync memory V2 status to dashboard on changes',
222224
category: 'notification',
223225
trigger: 'PostToolUse',
224-
matcher: 'core_memory',
226+
matcher: 'mcp__ccw-tools__core_memory',
225227
command: 'node',
226228
args: [
227229
'-e',

ccw/frontend/src/components/hook/HookWizard.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ interface HookTemplate {
8484
timeout?: number;
8585
}
8686

87+
// NOTE: Hook input is received via stdin (not environment variable)
88+
// Node.js: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');
89+
// Bash: INPUT=$(cat)
8790
const HOOK_TEMPLATES: Record<string, HookTemplate> = {
8891
'memory-update-queue': {
8992
event: 'Stop',
@@ -95,13 +98,13 @@ const HOOK_TEMPLATES: Record<string, HookTemplate> = {
9598
event: 'UserPromptSubmit',
9699
matcher: '',
97100
command: 'node',
98-
args: ['-e', "const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({prompt:p.user_prompt||''})],{stdio:'inherit'})"],
101+
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'})"],
99102
},
100103
'skill-context-auto': {
101104
event: 'UserPromptSubmit',
102105
matcher: '',
103106
command: 'node',
104-
args: ['-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'})"],
107+
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'})"],
105108
},
106109
'danger-bash-confirm': {
107110
event: 'PreToolUse',
@@ -114,7 +117,7 @@ const HOOK_TEMPLATES: Record<string, HookTemplate> = {
114117
event: 'PreToolUse',
115118
matcher: 'Write|Edit',
116119
command: 'bash',
117-
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\\"}}" && exit 0; fi; exit 0'],
120+
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'],
118121
timeout: 5000,
119122
},
120123
'danger-git-destructive': {
@@ -135,7 +138,7 @@ const HOOK_TEMPLATES: Record<string, HookTemplate> = {
135138
event: 'PreToolUse',
136139
matcher: 'Write|Edit|Bash',
137140
command: 'bash',
138-
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\\"}}" && exit 0; fi; fi; exit 0'],
141+
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'],
139142
timeout: 5000,
140143
},
141144
'danger-permission-change': {

ccw/src/core/routes/hooks-routes.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,75 @@ function getHooksConfig(projectPath: string): { global: { path: string; hooks: u
9494
};
9595
}
9696

97+
/**
98+
* Normalize hook data to Claude Code's official nested format
99+
* Official format: { matcher?: string, hooks: [{ type: 'command', command: string, timeout?: number }] }
100+
*
101+
* IMPORTANT: All timeout values from frontend are in MILLISECONDS and must be converted to SECONDS.
102+
* Official Claude Code spec requires timeout in seconds.
103+
*
104+
* @param {Object} hookData - Hook configuration (may be flat or nested format)
105+
* @returns {Object} Normalized hook data in official format
106+
*/
107+
function normalizeHookFormat(hookData: Record<string, unknown>): Record<string, unknown> {
108+
/**
109+
* Convert timeout from milliseconds to seconds
110+
* Frontend always sends milliseconds, Claude Code expects seconds
111+
*/
112+
const convertTimeout = (timeout: number): number => {
113+
// Always convert from milliseconds to seconds
114+
// This is safe because:
115+
// - Frontend (HookWizard) uses milliseconds (e.g., 5000ms)
116+
// - Claude Code official spec requires seconds
117+
// - Minimum valid timeout is 1 second, so any value < 1000ms becomes 1s
118+
return Math.max(1, Math.ceil(timeout / 1000));
119+
};
120+
121+
// If already in nested format with hooks array, validate and convert
122+
if (hookData.hooks && Array.isArray(hookData.hooks)) {
123+
// Ensure each hook in the array has required fields
124+
const normalizedHooks = (hookData.hooks as Array<Record<string, unknown>>).map(h => {
125+
const normalized: Record<string, unknown> = {
126+
type: h.type || 'command',
127+
command: h.command || '',
128+
};
129+
// Convert timeout from milliseconds to seconds
130+
if (typeof h.timeout === 'number') {
131+
normalized.timeout = convertTimeout(h.timeout);
132+
}
133+
return normalized;
134+
});
135+
136+
return {
137+
...(hookData.matcher !== undefined ? { matcher: hookData.matcher } : { matcher: '' }),
138+
hooks: normalizedHooks,
139+
};
140+
}
141+
142+
// Convert flat format to nested format
143+
// Old format: { command: '...', timeout: 5000, name: '...', failMode: '...' }
144+
// New format: { matcher: '', hooks: [{ type: 'command', command: '...', timeout: 5 }] }
145+
if (hookData.command && typeof hookData.command === 'string') {
146+
const nestedHook: Record<string, unknown> = {
147+
type: 'command',
148+
command: hookData.command,
149+
};
150+
151+
// Convert timeout from milliseconds to seconds
152+
if (typeof hookData.timeout === 'number') {
153+
nestedHook.timeout = convertTimeout(hookData.timeout);
154+
}
155+
156+
return {
157+
matcher: typeof hookData.matcher === 'string' ? hookData.matcher : '',
158+
hooks: [nestedHook],
159+
};
160+
}
161+
162+
// Return as-is if we can't normalize (let Claude Code validate)
163+
return hookData;
164+
}
165+
97166
/**
98167
* Save a hook to settings file
99168
* @param {string} projectPath
@@ -125,17 +194,19 @@ function saveHookToSettings(
125194
settings.hooks[event] = [settings.hooks[event]];
126195
}
127196

197+
// Normalize hook data to official format
198+
const normalizedData = normalizeHookFormat(hookData);
199+
128200
// Check if we're replacing an existing hook
129201
if (typeof hookData.replaceIndex === 'number') {
130202
const index = hookData.replaceIndex;
131-
delete hookData.replaceIndex;
132203
const hooksForEvent = settings.hooks[event] as unknown[];
133204
if (index >= 0 && index < hooksForEvent.length) {
134-
hooksForEvent[index] = hookData;
205+
hooksForEvent[index] = normalizedData;
135206
}
136207
} else {
137208
// Add new hook
138-
(settings.hooks[event] as unknown[]).push(hookData);
209+
(settings.hooks[event] as unknown[]).push(normalizedData);
139210
}
140211

141212
// Ensure directory exists and write file

ccw/src/core/routes/system-routes.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -202,22 +202,27 @@ function installRecommendedHook(
202202
settings.hooks[event] = [];
203203
}
204204

205-
// Check if hook already exists (by command)
205+
// Check if hook already exists (by command in nested hooks array)
206206
const existingHooks = (settings.hooks[event] || []) as Array<Record<string, unknown>>;
207-
const existingIndex = existingHooks.findIndex(
208-
(h) => (h as Record<string, unknown>).command === hook.command
209-
);
207+
const existingIndex = existingHooks.findIndex((entry) => {
208+
const hooks = (entry as Record<string, unknown>).hooks as Array<Record<string, unknown>> | undefined;
209+
if (!hooks || !Array.isArray(hooks)) return false;
210+
return hooks.some((h) => (h as Record<string, unknown>).command === hook.command);
211+
});
210212

211213
if (existingIndex >= 0) {
212214
return { success: true, installed: { id: hookId, event, status: 'already-exists' } };
213215
}
214216

215-
// Add new hook
217+
// Add new hook in Claude Code's official nested format
218+
// Format: { matcher: '', hooks: [{ type: 'command', command: '...', timeout: 5 }] }
216219
settings.hooks[event].push({
217-
name: hook.name,
218-
command: hook.command,
219-
timeout: 5000,
220-
failMode: 'silent'
220+
matcher: '',
221+
hooks: [{
222+
type: 'command',
223+
command: hook.command,
224+
timeout: 5 // seconds, not milliseconds
225+
}]
221226
});
222227

223228
// Ensure directory exists

0 commit comments

Comments
 (0)