mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-14 17:41:22 +08:00
feat(hooks): introduce hook templates management and execution
- Added a new command `ccw hook template` with subcommands for listing, installing, and executing templates. - Implemented backend support for managing hook templates, including API routes for fetching and installing templates. - Created a new file `hook-templates.ts` to define and manage hook templates, including their execution logic. - Added a migration script to convert old-style hooks to the new template-based approach. - Updated documentation to reflect new template commands and usage examples. - Enhanced error handling and output formatting for better user experience.
This commit is contained in:
703
ccw/src/core/hooks/hook-templates.ts
Normal file
703
ccw/src/core/hooks/hook-templates.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* Hook Templates - Backend Template Definitions
|
||||
*
|
||||
* All hook templates are defined here and executed via `ccw hook template exec <id> --stdin`.
|
||||
* This avoids Windows Git Bash quote handling issues when inline scripts are used.
|
||||
*
|
||||
* Usage:
|
||||
* ccw hook template list - List available templates
|
||||
* ccw hook template install <id> [--scope project|global] - Install template to settings.json
|
||||
* ccw hook template exec <id> --stdin - Execute template logic (for hooks)
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type HookTriggerType =
|
||||
| 'SessionStart'
|
||||
| 'UserPromptSubmit'
|
||||
| 'PreToolUse'
|
||||
| 'PostToolUse'
|
||||
| 'Notification'
|
||||
| 'Stop'
|
||||
| 'PreCompact';
|
||||
|
||||
export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility' | 'protection';
|
||||
|
||||
export interface HookTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: TemplateCategory;
|
||||
trigger: HookTriggerType;
|
||||
matcher?: string;
|
||||
timeout?: number;
|
||||
/** Execute function - receives parsed stdin data */
|
||||
execute: (data: HookInputData) => HookOutput | Promise<HookOutput>;
|
||||
}
|
||||
|
||||
export interface HookInputData {
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
prompt?: string;
|
||||
user_prompt?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
stop_reason?: string;
|
||||
stopReason?: string;
|
||||
end_turn_reason?: string;
|
||||
endTurnReason?: string;
|
||||
user_requested?: boolean;
|
||||
userRequested?: boolean;
|
||||
active_mode?: string;
|
||||
activeMode?: string;
|
||||
active_workflow?: boolean;
|
||||
activeWorkflow?: boolean;
|
||||
transcript_path?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HookOutput {
|
||||
/** Exit code: 0 = success, 2 = block */
|
||||
exitCode?: 0 | 2;
|
||||
/** stdout content (for system message injection) */
|
||||
stdout?: string;
|
||||
/** stderr content (for error messages) */
|
||||
stderr?: string;
|
||||
/** JSON output for hook decision */
|
||||
jsonOutput?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send notification to dashboard via HTTP
|
||||
*/
|
||||
function notifyDashboard(type: string, payload: Record<string, unknown>): void {
|
||||
const data = JSON.stringify({
|
||||
type,
|
||||
...payload,
|
||||
project: process.env.CLAUDE_PROJECT_DIR || process.cwd(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
spawnSync('curl', [
|
||||
'-s', '-X', 'POST',
|
||||
'-H', 'Content-Type: application/json',
|
||||
'-d', data,
|
||||
'http://localhost:3456/api/hook'
|
||||
], { stdio: 'inherit', shell: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file matches sensitive patterns
|
||||
*/
|
||||
function isSensitiveFile(filePath: string): boolean {
|
||||
return /\.env|secret|credential|\.key$|\.pem$|id_rsa|\.credentials/i.test(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command matches dangerous patterns
|
||||
*/
|
||||
function isDangerousCommand(cmd: string): boolean {
|
||||
const patterns = [
|
||||
/rm\s+-rf/i,
|
||||
/rmdir/i,
|
||||
/del\s+\//i,
|
||||
/format\s+/i,
|
||||
/shutdown/i,
|
||||
/reboot/i,
|
||||
/kill\s+-9/i,
|
||||
/pkill/i,
|
||||
/mkfs/i,
|
||||
/dd\s+if=/i,
|
||||
/chmod\s+777/i,
|
||||
/chown\s+-R/i,
|
||||
/>\s*\/dev\//i,
|
||||
/wget.*\|.*sh/i,
|
||||
/curl.*\|.*bash/i,
|
||||
];
|
||||
return patterns.some(p => p.test(cmd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command is a dangerous git operation
|
||||
*/
|
||||
function isDangerousGitCommand(cmd: string): boolean {
|
||||
const patterns = [
|
||||
/git\s+push.*--force/i,
|
||||
/git\s+push.*-f/i,
|
||||
/git\s+reset\s+--hard/i,
|
||||
/git\s+clean\s+-fd/i,
|
||||
/git\s+checkout.*--force/i,
|
||||
/git\s+branch\s+-D/i,
|
||||
/git\s+rebase.*-f/i,
|
||||
];
|
||||
return patterns.some(p => p.test(cmd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is in protected system paths
|
||||
*/
|
||||
function isSystemPath(path: string): boolean {
|
||||
const sysPatterns = [
|
||||
/\/etc\//i,
|
||||
/\/usr\//i,
|
||||
/\/bin\//i,
|
||||
/\/sbin\//i,
|
||||
/\/boot\//i,
|
||||
/\/sys\//i,
|
||||
/\/proc\//i,
|
||||
/C:\\Windows/i,
|
||||
/C:\\Program Files/i,
|
||||
];
|
||||
return sysPatterns.some(p => p.test(path));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Templates
|
||||
// ============================================================================
|
||||
|
||||
export const HOOK_TEMPLATES: HookTemplate[] = [
|
||||
// ============ Notification Templates ============
|
||||
{
|
||||
id: 'session-start-notify',
|
||||
name: 'Session Start Notify',
|
||||
description: 'Notify dashboard when a new workflow session is created',
|
||||
category: 'notification',
|
||||
trigger: 'SessionStart',
|
||||
execute: () => {
|
||||
notifyDashboard('SESSION_CREATED', {});
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'session-state-watch',
|
||||
name: 'Session State Watch',
|
||||
description: 'Watch for session metadata file changes (workflow-session.json)',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (/workflow-session\.json$|session-metadata\.json$/.test(file)) {
|
||||
try {
|
||||
if (existsSync(file)) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const sessionData = JSON.parse(content);
|
||||
notifyDashboard('SESSION_STATE_CHANGED', {
|
||||
file,
|
||||
sessionId: sessionData.session_id || '',
|
||||
status: sessionData.status || 'unknown',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'stop-notify',
|
||||
name: 'Stop Notify',
|
||||
description: 'Notify dashboard when Claude finishes responding',
|
||||
category: 'notification',
|
||||
trigger: 'Stop',
|
||||
execute: () => {
|
||||
notifyDashboard('TASK_COMPLETED', {});
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'memory-sync-dashboard',
|
||||
name: 'Memory Sync Dashboard',
|
||||
description: 'Sync memory V2 status to dashboard on changes',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'mcp__ccw-tools__core_memory',
|
||||
execute: () => {
|
||||
notifyDashboard('MEMORY_V2_STATUS_UPDATED', {});
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Automation Templates ============
|
||||
{
|
||||
id: 'auto-format-on-write',
|
||||
name: 'Auto Format on Write',
|
||||
description: 'Auto-format files after Claude writes or edits them',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (file) {
|
||||
spawnSync('npx', ['prettier', '--write', file], { stdio: 'inherit', shell: true });
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'auto-lint-on-write',
|
||||
name: 'Auto Lint on Write',
|
||||
description: 'Auto-lint files after Claude writes or edits them',
|
||||
category: 'automation',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (file) {
|
||||
spawnSync('npx', ['eslint', '--fix', file], { stdio: 'inherit', shell: true });
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'git-auto-stage',
|
||||
name: 'Git Auto Stage',
|
||||
description: 'Auto stage all modified files when Claude finishes responding',
|
||||
category: 'automation',
|
||||
trigger: 'Stop',
|
||||
execute: () => {
|
||||
spawnSync('git', ['add', '-u'], { stdio: 'inherit', shell: true });
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Protection Templates ============
|
||||
{
|
||||
id: 'block-sensitive-files',
|
||||
name: 'Block Sensitive Files',
|
||||
description: 'Block modifications to sensitive files (.env, secrets, credentials)',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (isSensitiveFile(file)) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
stderr: `Blocked: modifying sensitive file ${file}`,
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-bash-confirm',
|
||||
name: 'Danger Bash Confirm',
|
||||
description: 'Require confirmation for dangerous bash commands',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Bash',
|
||||
execute: (data) => {
|
||||
const cmd = (data.tool_input?.command as string) || '';
|
||||
if (isDangerousCommand(cmd)) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Potentially dangerous command detected: requires user confirmation`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-file-protection',
|
||||
name: 'Danger File Protection',
|
||||
description: 'Block modifications to protected files',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
const protectedPatterns = /\.env|\.git\/|package-lock\.json|yarn\.lock|\.credentials|secrets|id_rsa|\.pem$|\.key$/i;
|
||||
if (protectedPatterns.test(file)) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: `Protected file cannot be modified: ${file}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-git-destructive',
|
||||
name: 'Danger Git Destructive',
|
||||
description: 'Require confirmation for destructive git operations',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Bash',
|
||||
execute: (data) => {
|
||||
const cmd = (data.tool_input?.command as string) || '';
|
||||
if (isDangerousGitCommand(cmd)) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Destructive git operation detected: ${cmd}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-network-confirm',
|
||||
name: 'Danger Network Confirm',
|
||||
description: 'Require confirmation for network operations',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Bash|WebFetch',
|
||||
execute: (data) => {
|
||||
const tool = data.tool_name || '';
|
||||
|
||||
if (tool === 'WebFetch') {
|
||||
const url = (data.tool_input?.url as string) || '';
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Network request to: ${url}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const cmd = (data.tool_input?.command as string) || '';
|
||||
const netCmds = /^(curl|wget|nc |netcat|ssh |scp |rsync|ftp )/i;
|
||||
if (netCmds.test(cmd)) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Network command requires confirmation: ${cmd}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-system-paths',
|
||||
name: 'Danger System Paths',
|
||||
description: 'Block modifications to system paths',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Write|Edit|Bash',
|
||||
execute: (data) => {
|
||||
const tool = data.tool_name || '';
|
||||
|
||||
if (tool === 'Bash') {
|
||||
const cmd = (data.tool_input?.command as string) || '';
|
||||
if (isSystemPath(cmd)) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `System path operation requires confirmation`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (isSystemPath(file)) {
|
||||
return {
|
||||
exitCode: 2,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: `Cannot modify system file: ${file}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'danger-permission-change',
|
||||
name: 'Danger Permission Change',
|
||||
description: 'Require confirmation for permission changes',
|
||||
category: 'protection',
|
||||
trigger: 'PreToolUse',
|
||||
matcher: 'Bash',
|
||||
execute: (data) => {
|
||||
const cmd = (data.tool_input?.command as string) || '';
|
||||
const permCmds = /^(chmod|chown|chgrp|setfacl|icacls|takeown|cacls)/i;
|
||||
if (permCmds.test(cmd)) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
jsonOutput: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: `Permission change requires confirmation: ${cmd}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Indexing Templates ============
|
||||
{
|
||||
id: 'post-edit-index',
|
||||
name: 'Post Edit Index',
|
||||
description: 'Notify indexing service when files are modified',
|
||||
category: 'indexing',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write|Edit',
|
||||
execute: (data) => {
|
||||
const file = (data.tool_input?.file_path as string) || '';
|
||||
if (file) {
|
||||
notifyDashboard('FILE_MODIFIED', { file });
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'session-end-summary',
|
||||
name: 'Session End Summary',
|
||||
description: 'Send session summary to dashboard on session end',
|
||||
category: 'indexing',
|
||||
trigger: 'Stop',
|
||||
execute: (data) => {
|
||||
notifyDashboard('SESSION_SUMMARY', {
|
||||
transcript: data.transcript_path || '',
|
||||
});
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Utility Templates ============
|
||||
{
|
||||
id: 'memory-auto-compress',
|
||||
name: 'Auto Memory Compress',
|
||||
description: 'Automatically compress memory when entries exceed threshold',
|
||||
category: 'utility',
|
||||
trigger: 'Stop',
|
||||
execute: () => {
|
||||
spawnSync('ccw', ['memory', 'consolidate', '--threshold', '50'], { stdio: 'inherit', shell: true });
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'memory-preview-extract',
|
||||
name: 'Memory Preview & Extract',
|
||||
description: 'Preview extraction queue and extract eligible sessions',
|
||||
category: 'utility',
|
||||
trigger: 'SessionStart',
|
||||
execute: () => {
|
||||
spawnSync('ccw', ['memory', 'preview', '--include-native'], { stdio: 'inherit', shell: true });
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'memory-status-check',
|
||||
name: 'Memory Status Check',
|
||||
description: 'Check memory extraction and consolidation status',
|
||||
category: 'utility',
|
||||
trigger: 'SessionStart',
|
||||
execute: () => {
|
||||
spawnSync('ccw', ['memory', 'status'], { stdio: 'inherit', shell: true });
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'memory-v2-extract',
|
||||
name: 'Memory V2 Extract',
|
||||
description: 'Trigger Phase 1 extraction when session ends',
|
||||
category: 'utility',
|
||||
trigger: 'Stop',
|
||||
execute: () => {
|
||||
spawnSync('ccw', ['core-memory', 'extract', '--max-sessions', '10'], { stdio: 'inherit', shell: true });
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'memory-v2-auto-consolidate',
|
||||
name: 'Memory V2 Auto Consolidate',
|
||||
description: 'Trigger Phase 2 consolidation after extraction jobs complete',
|
||||
category: 'utility',
|
||||
trigger: 'Stop',
|
||||
execute: () => {
|
||||
const result = spawnSync('ccw', ['core-memory', 'extract', '--json'], {
|
||||
encoding: 'utf8',
|
||||
shell: true
|
||||
});
|
||||
try {
|
||||
const d = JSON.parse(result.stdout);
|
||||
if (d && d.total_stage1 >= 5) {
|
||||
spawnSync('ccw', ['core-memory', 'consolidate'], { stdio: 'inherit', shell: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Template Registry
|
||||
// ============================================================================
|
||||
|
||||
const templateMap = new Map<string, HookTemplate>();
|
||||
HOOK_TEMPLATES.forEach(t => templateMap.set(t.id, t));
|
||||
|
||||
/**
|
||||
* Get template by ID
|
||||
*/
|
||||
export function getTemplate(id: string): HookTemplate | undefined {
|
||||
return templateMap.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all templates grouped by category
|
||||
*/
|
||||
export function listTemplatesByCategory(): Record<TemplateCategory, HookTemplate[]> {
|
||||
const result: Record<TemplateCategory, HookTemplate[]> = {
|
||||
notification: [],
|
||||
indexing: [],
|
||||
automation: [],
|
||||
utility: [],
|
||||
protection: [],
|
||||
};
|
||||
HOOK_TEMPLATES.forEach(t => {
|
||||
result[t.category].push(t);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all templates
|
||||
*/
|
||||
export function getAllTemplates(): HookTemplate[] {
|
||||
return [...HOOK_TEMPLATES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a template by ID
|
||||
*/
|
||||
export async function executeTemplate(id: string, data: HookInputData): Promise<HookOutput> {
|
||||
const template = templateMap.get(id);
|
||||
if (!template) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
stderr: `Template not found: ${id}`,
|
||||
};
|
||||
}
|
||||
return template.execute(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate settings.json hook configuration for a template
|
||||
*/
|
||||
export function generateHookConfig(template: HookTemplate): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {
|
||||
_templateId: template.id,
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: `ccw hook template exec ${template.id} --stdin`,
|
||||
...(template.timeout ? { timeout: template.timeout } : {}),
|
||||
}],
|
||||
};
|
||||
|
||||
if (template.matcher) {
|
||||
config.matcher = template.matcher;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a template to settings.json
|
||||
*/
|
||||
export function installTemplateToSettings(
|
||||
templateId: string,
|
||||
scope: 'project' | 'global' = 'project'
|
||||
): { success: boolean; message: string } {
|
||||
const template = templateMap.get(templateId);
|
||||
if (!template) {
|
||||
return { success: false, message: `Template not found: ${templateId}` };
|
||||
}
|
||||
|
||||
const settingsPath = scope === 'global'
|
||||
? join(homedir(), '.claude', 'settings.json')
|
||||
: join(process.cwd(), '.claude', 'settings.json');
|
||||
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||
} catch {
|
||||
return { success: false, message: `Failed to parse ${settingsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize hooks structure
|
||||
if (!settings.hooks) {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown[]>;
|
||||
if (!hooks[template.trigger]) {
|
||||
hooks[template.trigger] = [];
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const triggerHooks = hooks[template.trigger];
|
||||
const alreadyInstalled = triggerHooks.some((h: Record<string, unknown>) =>
|
||||
h._templateId === templateId
|
||||
);
|
||||
|
||||
if (alreadyInstalled) {
|
||||
return { success: true, message: `Template ${templateId} already installed` };
|
||||
}
|
||||
|
||||
// Add the hook
|
||||
triggerHooks.push(generateHookConfig(template));
|
||||
|
||||
// Write back
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return { success: true, message: `Template ${templateId} installed to ${settingsPath}` };
|
||||
} catch (e) {
|
||||
return { success: false, message: `Failed to write settings: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
@@ -708,6 +708,78 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get hook templates list
|
||||
if (pathname === '/api/hooks/templates' && req.method === 'GET') {
|
||||
(async () => {
|
||||
try {
|
||||
const { getAllTemplates, listTemplatesByCategory } = await import('../hooks/hook-templates.js');
|
||||
const category = url.searchParams.get('category');
|
||||
|
||||
if (category) {
|
||||
const byCategory = listTemplatesByCategory();
|
||||
const templates = byCategory[category as keyof typeof byCategory] || [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, templates }));
|
||||
} else {
|
||||
const templates = getAllTemplates();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, templates }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Hooks] Failed to get templates:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Install hook template
|
||||
if (pathname === '/api/hooks/templates/install' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return { error: 'Invalid request body', status: 400 };
|
||||
}
|
||||
|
||||
const { templateId, scope = 'project', projectPath } = body as {
|
||||
templateId?: unknown;
|
||||
scope?: unknown;
|
||||
projectPath?: unknown;
|
||||
};
|
||||
|
||||
if (typeof templateId !== 'string') {
|
||||
return { error: 'templateId is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const { installTemplateToSettings } = await import('../hooks/hook-templates.js');
|
||||
const resolvedProjectPath = typeof projectPath === 'string' && projectPath.trim().length > 0
|
||||
? projectPath
|
||||
: initialPath;
|
||||
|
||||
// Override process.cwd() for project-scoped installation
|
||||
const originalCwd = process.cwd;
|
||||
if (scope === 'project') {
|
||||
process.cwd = () => resolvedProjectPath;
|
||||
}
|
||||
|
||||
const result = installTemplateToSettings(
|
||||
templateId,
|
||||
(scope === 'global' ? 'global' : 'project') as 'global' | 'project'
|
||||
);
|
||||
|
||||
// Restore original cwd
|
||||
process.cwd = originalCwd;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Hooks] Failed to install template:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user