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:
catlog22
2026-03-03 10:07:34 +08:00
parent 59787dc9be
commit 9613644fc4
6 changed files with 1353 additions and 158 deletions

View File

@@ -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 <id> --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 <id> --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 ==========

View File

@@ -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<string> {
return new Promise((resolve) => {
@@ -715,6 +727,164 @@ async function notifyAction(options: HookOptions): Promise<void> {
}
}
/**
* Template action - manage and execute hook templates
*
* Subcommands:
* list - List all available templates
* install <id> - Install a template to settings.json
* exec <id> - Execute a template (for hooks)
*/
async function templateAction(subcommand: string, args: string[], options: HookOptions): Promise<void> {
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<string, string> = {
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 <id> [--scope project|global]'));
console.log(chalk.gray(' ccw hook template exec <id> --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 <id> [--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 <id> --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 <list|install|exec> [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 <id> [--global] Install a template to settings.json
template exec <id> --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<void> {
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();

View 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}` };
}
}

View File

@@ -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;
}

View File

@@ -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<string, OldHookEntry[]>;
[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<string, string> = {
// 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<void> {
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);

View File

@@ -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 {