From 9613644fc439a858a90765e0db39ca53926a8f9c Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 3 Mar 2026 10:07:34 +0800 Subject: [PATCH] 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. --- .../components/hook/HookQuickTemplates.tsx | 276 ++++--- ccw/src/commands/hook.ts | 205 ++++- ccw/src/core/hooks/hook-templates.ts | 703 ++++++++++++++++++ ccw/src/core/routes/hooks-routes.ts | 72 ++ ccw/src/scripts/migrate-hook-templates.ts | 227 ++++++ docs/.vitepress/theme/styles/custom.css | 28 +- 6 files changed, 1353 insertions(+), 158 deletions(-) create mode 100644 ccw/src/core/hooks/hook-templates.ts create mode 100644 ccw/src/scripts/migrate-hook-templates.ts diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx index dce58b9a..83440d87 100644 --- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx +++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx @@ -1,7 +1,9 @@ // ======================================== // Hook Quick Templates Component // ======================================== -// Predefined hook templates for quick installation +// Frontend component for displaying and installing hook templates +// Templates are defined in backend: ccw/src/core/hooks/hook-templates.ts +// All templates use `ccw hook template exec --stdin` to avoid Windows quote issues import { useMemo } from 'react'; import { useIntl } from 'react-intl'; @@ -32,10 +34,10 @@ import type { HookTriggerType } from './HookCard'; /** * Template category type */ -export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility'; +export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility' | 'protection'; /** - * Hook template definition + * Hook template definition (frontend view of backend templates) */ export interface HookTemplate { id: string; @@ -43,8 +45,6 @@ export interface HookTemplate { description: string; category: TemplateCategory; trigger: HookTriggerType; - command: string; - args?: string[]; matcher?: string; } @@ -63,24 +63,22 @@ export interface HookQuickTemplatesProps { } // ========== Hook Templates ========== -// NOTE: Hook input is received via stdin (not environment variable) -// Use: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); +// NOTE: Templates are defined in backend (ccw/src/core/hooks/hook-templates.ts) +// This is a copy for frontend display purposes. +// All templates use `ccw hook template exec --stdin` format. /** * Predefined hook templates for quick installation + * These mirror the backend templates in ccw/src/core/hooks/hook-templates.ts */ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ + // ============ Notification ============ { id: 'session-start-notify', name: 'Session Start Notify', description: 'Notify dashboard when a new workflow session is created', category: 'notification', trigger: 'SessionStart', - command: 'node', - args: [ - '-e', - 'const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_CREATED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' - ] }, { id: 'session-state-watch', @@ -89,133 +87,13 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ category: 'notification', trigger: 'PostToolUse', matcher: 'Write|Edit', - command: 'node', - args: [ - '-e', - '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){}}' - ] }, - // --- Notification --- { id: 'stop-notify', name: 'Stop Notify', description: 'Notify dashboard when Claude finishes responding', category: 'notification', trigger: 'Stop', - command: 'node', - args: [ - '-e', - 'const cp=require("child_process");const payload=JSON.stringify({type:"TASK_COMPLETED",timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' - ] - }, - // --- Automation --- - { - 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', - command: 'node', - args: [ - '-e', - '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})}' - ] - }, - { - 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', - command: 'node', - args: [ - '-e', - '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})}' - ] - }, - { - id: 'block-sensitive-files', - name: 'Block Sensitive Files', - description: 'Block modifications to sensitive files (.env, secrets, credentials)', - category: 'automation', - trigger: 'PreToolUse', - matcher: 'Write|Edit', - command: 'node', - args: [ - '-e', - '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)}' - ] - }, - { - id: 'git-auto-stage', - name: 'Git Auto Stage', - description: 'Auto stage all modified files when Claude finishes responding', - category: 'automation', - trigger: 'Stop', - command: 'node', - args: [ - '-e', - 'const cp=require("child_process");cp.spawnSync("git",["add","-u"],{stdio:"inherit",shell:true})' - ] - }, - // --- Indexing --- - { - id: 'post-edit-index', - name: 'Post Edit Index', - description: 'Notify indexing service when files are modified', - category: 'indexing', - trigger: 'PostToolUse', - matcher: 'Write|Edit', - command: 'node', - args: [ - '-e', - '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})}' - ] - }, - { - id: 'session-end-summary', - name: 'Session End Summary', - description: 'Send session summary to dashboard on session end', - category: 'indexing', - trigger: 'Stop', - command: 'node', - args: [ - '-e', - '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})' - ] - }, - { - id: 'project-state-inject', - name: 'Project State Inject', - description: 'Inject project guidelines and recent dev history at session start', - category: 'indexing', - trigger: 'SessionStart', - command: 'ccw', - args: ['hook', 'project-state', '--stdin'] - }, - // --- Memory V2 --- - { - id: 'memory-v2-extract', - name: 'Memory V2 Extract', - description: 'Trigger Phase 1 extraction when session ends (after idle period)', - category: 'indexing', - trigger: 'Stop', - command: 'ccw', - args: ['core-memory', 'extract', '--max-sessions', '10'] - }, - { - id: 'memory-v2-auto-consolidate', - name: 'Memory V2 Auto Consolidate', - description: 'Trigger Phase 2 consolidation after extraction jobs complete', - category: 'indexing', - trigger: 'Stop', - command: 'node', - args: [ - '-e', - 'const cp=require("child_process");const r=cp.spawnSync("ccw",["core-memory","extract","--json"],{encoding:"utf8",shell:true});try{const d=JSON.parse(r.stdout);if(d&&d.total_stage1>=5){cp.spawnSync("ccw",["core-memory","consolidate"],{stdio:"inherit",shell:true})}}catch(e){}' - ] }, { id: 'memory-sync-dashboard', @@ -224,30 +102,122 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ category: 'notification', trigger: 'PostToolUse', matcher: 'mcp__ccw-tools__core_memory', - command: 'node', - args: [ - '-e', - 'const cp=require("child_process");const payload=JSON.stringify({type:"MEMORY_V2_STATUS_UPDATED",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})' - ] }, - // --- Memory Operations --- + + // ============ Automation ============ + { + 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', + }, + { + 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', + }, + { + id: 'git-auto-stage', + name: 'Git Auto Stage', + description: 'Auto stage all modified files when Claude finishes responding', + category: 'automation', + trigger: 'Stop', + }, + + // ============ Protection ============ + { + id: 'block-sensitive-files', + name: 'Block Sensitive Files', + description: 'Block modifications to sensitive files (.env, secrets, credentials)', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Write|Edit', + }, + { + id: 'danger-bash-confirm', + name: 'Danger Bash Confirm', + description: 'Require confirmation for dangerous bash commands', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Bash', + }, + { + id: 'danger-file-protection', + name: 'Danger File Protection', + description: 'Block modifications to protected files', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Write|Edit', + }, + { + id: 'danger-git-destructive', + name: 'Danger Git Destructive', + description: 'Require confirmation for destructive git operations', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Bash', + }, + { + id: 'danger-network-confirm', + name: 'Danger Network Confirm', + description: 'Require confirmation for network operations', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Bash|WebFetch', + }, + { + id: 'danger-system-paths', + name: 'Danger System Paths', + description: 'Block modifications to system paths', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Write|Edit|Bash', + }, + { + id: 'danger-permission-change', + name: 'Danger Permission Change', + description: 'Require confirmation for permission changes', + category: 'protection', + trigger: 'PreToolUse', + matcher: 'Bash', + }, + + // ============ Indexing ============ + { + id: 'post-edit-index', + name: 'Post Edit Index', + description: 'Notify indexing service when files are modified', + category: 'indexing', + trigger: 'PostToolUse', + matcher: 'Write|Edit', + }, + { + id: 'session-end-summary', + name: 'Session End Summary', + description: 'Send session summary to dashboard on session end', + category: 'indexing', + trigger: 'Stop', + }, + + // ============ Utility ============ { id: 'memory-auto-compress', name: 'Auto Memory Compress', description: 'Automatically compress memory when entries exceed threshold', - category: 'automation', + category: 'utility', trigger: 'Stop', - command: 'ccw', - args: ['memory', 'consolidate', '--threshold', '50'] }, { id: 'memory-preview-extract', name: 'Memory Preview & Extract', description: 'Preview extraction queue and extract eligible sessions', - category: 'automation', + category: 'utility', trigger: 'SessionStart', - command: 'ccw', - args: ['memory', 'preview', '--include-native'] }, { id: 'memory-status-check', @@ -255,9 +225,21 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ description: 'Check memory extraction and consolidation status', category: 'utility', trigger: 'SessionStart', - command: 'ccw', - args: ['memory', 'status'] - } + }, + { + id: 'memory-v2-extract', + name: 'Memory V2 Extract', + description: 'Trigger Phase 1 extraction when session ends', + category: 'utility', + trigger: 'Stop', + }, + { + id: 'memory-v2-auto-consolidate', + name: 'Memory V2 Auto Consolidate', + description: 'Trigger Phase 2 consolidation after extraction jobs complete', + category: 'utility', + trigger: 'Stop', + }, ] as const; // ========== Category Icons ========== diff --git a/ccw/src/commands/hook.ts b/ccw/src/commands/hook.ts index 1e55665a..9fd3ae3b 100644 --- a/ccw/src/commands/hook.ts +++ b/ccw/src/commands/hook.ts @@ -1,6 +1,17 @@ /** * Hook Command - CLI endpoint for Claude Code hooks * Provides simplified interface for hook operations, replacing complex bash/curl commands + * + * Subcommands: + * parse-status - Parse CCW status.json + * session-context - Progressive session context loading + * session-end - Trigger background memory tasks + * stop - Handle Stop hook events + * keyword - Detect mode keywords + * pre-compact - Handle PreCompact events + * notify - Send notification to dashboard + * project-state - Output project state summary + * template - Manage and execute hook templates */ import chalk from 'chalk'; @@ -37,6 +48,7 @@ interface HookData { /** * Read JSON data from stdin (for Claude Code hooks) + * Returns the raw string data */ async function readStdin(): Promise { return new Promise((resolve) => { @@ -715,6 +727,164 @@ async function notifyAction(options: HookOptions): Promise { } } +/** + * Template action - manage and execute hook templates + * + * Subcommands: + * list - List all available templates + * install - Install a template to settings.json + * exec - Execute a template (for hooks) + */ +async function templateAction(subcommand: string, args: string[], options: HookOptions): Promise { + const { stdin } = options; + + // Dynamic import to avoid circular dependencies + const { + getTemplate, + getAllTemplates, + listTemplatesByCategory, + executeTemplate, + installTemplateToSettings, + type HookInputData, + } = await import('../core/hooks/hook-templates.js'); + + switch (subcommand) { + case 'list': { + const byCategory = listTemplatesByCategory(); + const categoryNames: Record = { + notification: 'Notification', + indexing: 'Indexing', + automation: 'Automation', + utility: 'Utility', + protection: 'Protection', + }; + + if (stdin) { + // JSON output for programmatic use + process.stdout.write(JSON.stringify(byCategory, null, 2)); + process.exit(0); + } + + console.log(chalk.green('Hook Templates')); + console.log(chalk.gray('─'.repeat(50))); + + for (const [category, templates] of Object.entries(byCategory)) { + if (templates.length === 0) continue; + console.log(chalk.cyan(`\n${categoryNames[category] || category}:`)); + for (const t of templates) { + console.log(` ${chalk.yellow(t.id)}`); + console.log(` ${chalk.gray(t.description)}`); + console.log(` Trigger: ${t.trigger}${t.matcher ? ` (${t.matcher})` : ''}`); + } + } + + console.log(chalk.gray('\n─'.repeat(50))); + console.log(chalk.gray('Usage: ccw hook template install [--scope project|global]')); + console.log(chalk.gray(' ccw hook template exec --stdin')); + process.exit(0); + } + + case 'install': { + const templateId = args[0]; + if (!templateId) { + console.error(chalk.red('Error: template ID required')); + console.error(chalk.gray('Usage: ccw hook template install [--scope project|global]')); + process.exit(1); + } + + const scope = args.includes('--global') ? 'global' : 'project'; + const result = installTemplateToSettings(templateId, scope); + + if (stdin) { + process.stdout.write(JSON.stringify(result)); + process.exit(result.success ? 0 : 1); + } + + if (result.success) { + console.log(chalk.green(result.message)); + process.exit(0); + } else { + console.error(chalk.red(result.message)); + process.exit(1); + } + break; + } + + case 'exec': { + const templateId = args[0]; + if (!templateId) { + if (stdin) { + process.exit(0); + } + console.error(chalk.red('Error: template ID required')); + console.error(chalk.gray('Usage: ccw hook template exec --stdin')); + process.exit(1); + } + + const template = getTemplate(templateId); + if (!template) { + if (stdin) { + process.exit(0); + } + console.error(chalk.red(`Template not found: ${templateId}`)); + process.exit(1); + } + + // Read hook data from stdin + let hookData: HookInputData = {}; + if (stdin) { + try { + const stdinData = await readStdin(); + if (stdinData) { + hookData = JSON.parse(stdinData) as HookInputData; + } + } catch { + // Continue with empty data + } + } + + try { + const output = await executeTemplate(templateId, hookData); + + // Handle JSON output for hook decisions + if (output.jsonOutput) { + process.stdout.write(JSON.stringify(output.jsonOutput)); + process.exit(output.exitCode || 0); + } + + // Handle stderr + if (output.stderr) { + process.stderr.write(output.stderr); + } + + // Handle stdout + if (output.stdout) { + process.stdout.write(output.stdout); + } + + process.exit(output.exitCode || 0); + } catch (error) { + if (stdin) { + // Silent failure for hooks + process.exit(0); + } + console.error(chalk.red(`Error: ${(error as Error).message}`)); + process.exit(1); + } + break; + } + + default: { + if (stdin) { + process.exit(0); + } + console.error(chalk.red(`Unknown template subcommand: ${subcommand}`)); + console.error(chalk.gray('Usage: ccw hook template [args]')); + process.exit(1); + } + } +} + /** * Project state action - reads project-tech.json and specs * and outputs a concise summary for session context injection. @@ -858,6 +1028,7 @@ ${chalk.bold('SUBCOMMANDS')} pre-compact Handle PreCompact hook events (checkpoint creation) notify Send notification to ccw view dashboard project-state Output project guidelines and recent dev history summary + template Manage and execute hook templates (list, install, exec) ${chalk.bold('OPTIONS')} --stdin Read input from stdin (for Claude Code hooks) @@ -866,6 +1037,11 @@ ${chalk.bold('OPTIONS')} --session-id Session ID (alternative to stdin) --prompt Current prompt text (alternative to stdin) +${chalk.bold('TEMPLATE SUBCOMMANDS')} + template list List all available hook templates + template install [--global] Install a template to settings.json + template exec --stdin Execute a template (for Claude Code hooks) + ${chalk.bold('EXAMPLES')} ${chalk.gray('# Parse CCW status file:')} ccw hook parse-status --path .workflow/.ccw/ccw-123/status.json @@ -894,6 +1070,15 @@ ${chalk.bold('EXAMPLES')} ${chalk.gray('# Project state summary (hook, reads cwd from stdin):')} ccw hook project-state --stdin + ${chalk.gray('# List available templates:')} + ccw hook template list + + ${chalk.gray('# Install a template:')} + ccw hook template install block-sensitive-files + + ${chalk.gray('# Execute a template (for hooks):')} + ccw hook template exec block-sensitive-files --stdin + ${chalk.bold('HOOK CONFIGURATION')} ${chalk.gray('Add to .claude/settings.json for Stop hook:')} { @@ -908,14 +1093,16 @@ ${chalk.bold('HOOK CONFIGURATION')} } } - ${chalk.gray('Add to .claude/settings.json for status tracking:')} + ${chalk.gray('Add to .claude/settings.json using templates (recommended):')} { "hooks": { - "PostToolUse": [{ - "trigger": "PostToolUse", - "matcher": "Write", - "command": "bash", - "args": ["-c", "INPUT=$(cat); FILE_PATH=$(echo \\"$INPUT\\" | jq -r \\".tool_input.file_path // empty\\"); [ -n \\"$FILE_PATH\\" ] && ccw hook parse-status --path \\"$FILE_PATH\\""] + "PreToolUse": [{ + "_templateId": "block-sensitive-files", + "matcher": "Write|Edit", + "hooks": [{ + "type": "command", + "command": "ccw hook template exec block-sensitive-files --stdin" + }] }] } } @@ -930,6 +1117,8 @@ export async function hookCommand( args: string | string[], options: HookOptions ): Promise { + const argsArray = Array.isArray(args) ? args : [args]; + switch (subcommand) { case 'parse-status': await parseStatusAction(options); @@ -957,6 +1146,10 @@ export async function hookCommand( case 'project-state': await projectStateAction(options); break; + case 'template': + // template has its own subcommands: list, install, exec + await templateAction(argsArray[0] || 'list', argsArray.slice(1), options); + break; case 'help': case undefined: showHelp(); diff --git a/ccw/src/core/hooks/hook-templates.ts b/ccw/src/core/hooks/hook-templates.ts new file mode 100644 index 00000000..a6fad16a --- /dev/null +++ b/ccw/src/core/hooks/hook-templates.ts @@ -0,0 +1,703 @@ +/** + * Hook Templates - Backend Template Definitions + * + * All hook templates are defined here and executed via `ccw hook template exec --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 [--scope project|global] - Install template to settings.json + * ccw hook template exec --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; +} + +export interface HookInputData { + session_id?: string; + cwd?: string; + prompt?: string; + user_prompt?: string; + tool_name?: string; + tool_input?: Record; + 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; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Send notification to dashboard via HTTP + */ +function notifyDashboard(type: string, payload: Record): 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(); +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 { + const result: Record = { + 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 { + 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 { + const config: Record = { + _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 = {}; + + 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; + if (!hooks[template.trigger]) { + hooks[template.trigger] = []; + } + + // Check if already installed + const triggerHooks = hooks[template.trigger]; + const alreadyInstalled = triggerHooks.some((h: Record) => + 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}` }; + } +} diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index 74abd786..c934caea 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -708,6 +708,78 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise { + 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; } diff --git a/ccw/src/scripts/migrate-hook-templates.ts b/ccw/src/scripts/migrate-hook-templates.ts new file mode 100644 index 00000000..260a72d5 --- /dev/null +++ b/ccw/src/scripts/migrate-hook-templates.ts @@ -0,0 +1,227 @@ +#!/usr/bin/env npx tsx +/** + * Migrate Hook Templates Script + * + * This script helps migrate hook templates from inline bash/node commands + * to the new `ccw hook template exec` approach, which avoids Windows Git Bash + * quote handling issues. + * + * Usage: + * npx tsx scripts/migrate-hook-templates.ts [--dry-run] [--settings path] + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +interface OldHookEntry { + matcher?: string; + command?: string; + hooks?: Array<{ + type?: string; + command?: string; + }>; +} + +interface Settings { + hooks?: Record; + [key: string]: unknown; +} + +// Command patterns that indicate old-style inline scripts +const OLD_PATTERNS = [ + // Bash inline with jq + /bash\s+-c.*jq/, + // Node inline with complex scripts + /node\s+-e.*child_process/, + /node\s+-e.*spawnSync/, + // Long inline commands + /command.*node -e ".*\{.*\}.*"/, +]; + +// Mapping from old patterns to new template IDs +const MIGRATION_MAP: Record = { + // Danger protection patterns + 'danger-bash-confirm': 'danger-bash-confirm', + 'danger-file-protection': 'danger-file-protection', + 'danger-git-destructive': 'danger-git-destructive', + 'danger-network-confirm': 'danger-network-confirm', + 'danger-system-paths': 'danger-system-paths', + 'danger-permission-change': 'danger-permission-change', + // Memory patterns + 'memory-update-queue': 'memory-auto-compress', + 'memory-v2-extract': 'memory-v2-extract', + // Notification patterns + 'session-start-notify': 'session-start-notify', + 'stop-notify': 'stop-notify', + 'session-state-watch': 'session-state-watch', + // Automation patterns + 'auto-format-on-write': 'auto-format-on-write', + 'auto-lint-on-write': 'auto-lint-on-write', + 'block-sensitive-files': 'block-sensitive-files', + 'git-auto-stage': 'git-auto-stage', + // Utility patterns + 'memory-preview-extract': 'memory-preview-extract', + 'memory-status-check': 'memory-status-check', + 'post-edit-index': 'post-edit-index', +}; + +function detectTemplateFromCommand(command: string): string | null { + // Check for explicit template ID patterns + for (const [pattern, templateId] of Object.entries(MIGRATION_MAP)) { + if (command.includes(pattern)) { + return templateId; + } + } + + // Check for jq usage in bash (indicates old-style danger detection) + if (command.includes('jq -r') && command.includes('DANGEROUS_PATTERNS')) { + return 'danger-bash-confirm'; + } + + // Check for curl to localhost:3456 (dashboard notification) + if (command.includes('localhost:3456/api/hook')) { + if (command.includes('SESSION_CREATED')) return 'session-start-notify'; + if (command.includes('TASK_COMPLETED')) return 'stop-notify'; + if (command.includes('FILE_MODIFIED')) return 'post-edit-index'; + if (command.includes('SESSION_STATE_CHANGED')) return 'session-state-watch'; + } + + // Check for prettier + if (command.includes('prettier --write')) { + return 'auto-format-on-write'; + } + + // Check for eslint + if (command.includes('eslint --fix')) { + return 'auto-lint-on-write'; + } + + // Check for git add + if (command.includes('git add -u')) { + return 'git-auto-stage'; + } + + // Check for sensitive file patterns + if (command.includes('.env') && command.includes('credential')) { + return 'block-sensitive-files'; + } + + return null; +} + +function isOldStyleHook(entry: OldHookEntry): boolean { + const command = entry.command || entry.hooks?.[0]?.command || ''; + return OLD_PATTERNS.some(pattern => pattern.test(command)); +} + +function migrateHookEntry(entry: OldHookEntry, trigger: string): OldHookEntry { + const command = entry.command || entry.hooks?.[0]?.command || ''; + const templateId = detectTemplateFromCommand(command); + + if (!templateId) { + console.log(` ⚠️ Could not auto-detect template for: ${command.substring(0, 50)}...`); + return entry; + } + + console.log(` ✓ Migrating to template: ${templateId}`); + + return { + _templateId: templateId, + matcher: entry.matcher, + hooks: [{ + type: 'command', + command: `ccw hook template exec ${templateId} --stdin`, + }], + }; +} + +function migrateSettings(settings: Settings, dryRun: boolean): Settings { + const migrated = { ...settings }; + + if (!migrated.hooks) { + return migrated; + } + + console.log('\n📋 Analyzing hooks...'); + + for (const [trigger, entries] of Object.entries(migrated.hooks)) { + if (!Array.isArray(entries)) continue; + + console.log(`\n${trigger}:`); + const newEntries: OldHookEntry[] = []; + + for (const entry of entries) { + if (isOldStyleHook(entry)) { + console.log(` Found old-style hook with matcher: ${entry.matcher || '*'}`); + const migratedEntry = migrateHookEntry(entry, trigger); + newEntries.push(migratedEntry); + } else { + // Check if already using template approach + if (entry.hooks?.[0]?.command?.includes('ccw hook template')) { + console.log(` ✓ Already using template: ${entry._templateId || 'unknown'}`); + } + newEntries.push(entry); + } + } + + migrated.hooks[trigger] = newEntries; + } + + return migrated; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + + let settingsPath: string; + const settingsIndex = args.indexOf('--settings'); + if (settingsIndex >= 0 && args[settingsIndex + 1]) { + settingsPath = args[settingsIndex + 1]; + } else { + // Default to project settings + settingsPath = join(process.cwd(), '.claude', 'settings.json'); + } + + console.log('🔧 Hook Template Migration Script'); + console.log('='.repeat(50)); + console.log(`Settings file: ${settingsPath}`); + console.log(`Mode: ${dryRun ? 'DRY RUN (no changes)' : 'LIVE (will modify)'}`); + + if (!existsSync(settingsPath)) { + console.error(`\n❌ Settings file not found: ${settingsPath}`); + process.exit(1); + } + + let settings: Settings; + try { + const content = readFileSync(settingsPath, 'utf8'); + settings = JSON.parse(content); + } catch (e) { + console.error(`\n❌ Failed to parse settings: ${(e as Error).message}`); + process.exit(1); + } + + const migrated = migrateSettings(settings, dryRun); + + if (dryRun) { + console.log('\n📄 Migrated settings (dry run):'); + console.log(JSON.stringify(migrated, null, 2)); + } else { + // Backup original + const backupPath = `${settingsPath}.backup-${Date.now()}`; + writeFileSync(backupPath, JSON.stringify(settings, null, 2)); + console.log(`\n💾 Backup saved to: ${backupPath}`); + + // Write migrated + writeFileSync(settingsPath, JSON.stringify(migrated, null, 2)); + console.log(`\n✅ Settings migrated successfully!`); + } + + console.log('\n📌 Next steps:'); + console.log(' 1. Review the migrated settings'); + console.log(' 2. Test your hooks to ensure they work correctly'); + console.log(' 3. Run "ccw hook template list" to see all available templates'); +} + +main().catch(console.error); diff --git a/docs/.vitepress/theme/styles/custom.css b/docs/.vitepress/theme/styles/custom.css index 0943d034..a4218465 100644 --- a/docs/.vitepress/theme/styles/custom.css +++ b/docs/.vitepress/theme/styles/custom.css @@ -898,10 +898,31 @@ textarea:focus-visible { * "Intelligent Responsive Content Width" section. */ @media (min-width: 1024px) { - /* Expand .content to fill available space */ + /* Remove padding from VPDoc to maximize content width */ + .VPDoc.has-aside { + padding-left: 0 !important; + padding-right: 0 !important; + } + + /* Remove padding from container to maximize content width */ + .VPDoc.has-aside .container { + justify-content: flex-start !important; + padding-left: 0 !important; + padding-right: 0 !important; + } + + /* Keep .aside at fixed width */ + .VPDoc.has-aside .container .aside { + flex: 0 0 auto !important; + } + + /* Expand .content to fill available space and remove padding */ .VPDoc.has-aside .container .content { - flex-grow: 1 !important; + flex: 1 1 0 !important; + min-width: 0 !important; max-width: none !important; + padding-left: 0 !important; + padding-right: 0 !important; } /* Use multiple selectors to increase specificity and override scoped styles */ @@ -910,9 +931,6 @@ textarea:focus-visible { .content-container { max-width: none !important; width: 100% !important; - min-width: 100% !important; - flex-grow: 1 !important; - flex-basis: 100% !important; } .vp-doc {