📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
---
|
||||
name: claude-hooks
|
||||
description: This skill should be used when creating hooks, automating workflows, or when "PreToolUse", "PostToolUse", "hooks.json", "event handler", or "create hook" are mentioned.
|
||||
metadata:
|
||||
version: "2.0.0"
|
||||
related-skills:
|
||||
- claude-commands
|
||||
- claude-plugins
|
||||
- claude-agents
|
||||
- claude-config
|
||||
---
|
||||
|
||||
# Claude Hook Authoring
|
||||
|
||||
Create event hooks that automate workflows, validate operations, and respond to Claude Code events.
|
||||
|
||||
## Hook Types
|
||||
|
||||
Three hook execution types:
|
||||
|
||||
| Type | Best For | Example |
|
||||
|------|----------|---------|
|
||||
| **command** | Deterministic checks, external tools, performance | Bash script validates paths |
|
||||
| **prompt** | Complex reasoning, context-aware validation | LLM evaluates if action is safe |
|
||||
| **agent** | Multi-step verification requiring tool access | Agent with Read/Grep tools verifies consistency |
|
||||
|
||||
**Command hooks** (for deterministic/fast checks):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Prompt hooks** (recommended for complex logic):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "prompt",
|
||||
"prompt": "Evaluate if this file write is safe: $TOOL_INPUT. Check for sensitive paths, credentials, path traversal. Return 'allow' or 'deny' with reason.",
|
||||
"timeout": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Agent hooks** (for complex multi-step verification):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "agent",
|
||||
"prompt": "Verify this code change maintains consistency with the existing codebase. Check imports, type signatures, and naming conventions. Use Read and Grep tools as needed.",
|
||||
"allowedTools": ["Read", "Grep", "Glob"],
|
||||
"timeout": 120
|
||||
}
|
||||
```
|
||||
|
||||
Agent hooks spawn a subagent with tool access for verification tasks that require reading files, searching code, or multi-step reasoning. Use when prompt hooks are insufficient.
|
||||
|
||||
## Hook Events
|
||||
|
||||
| Event | When | Can Block | Common Uses |
|
||||
|-------|------|-----------|-------------|
|
||||
| **PreToolUse** | Before tool executes | Yes | Validate commands, check paths, enforce policies |
|
||||
| **PostToolUse** | After tool succeeds | No | Auto-format, run linters, update docs |
|
||||
| **PostToolUseFailure** | After tool fails | No | Error logging, retry logic, notifications |
|
||||
| **PermissionRequest** | Permission dialog shown | Yes | Auto-allow/deny based on rules |
|
||||
| **UserPromptSubmit** | User submits prompt | No | Add context, log activity, augment prompts |
|
||||
| **Notification** | Claude sends notification | No | External alerts, logging |
|
||||
| **Stop** | Main agent finishes | No | Cleanup, completion notifications |
|
||||
| **SubagentStart** | Subagent spawns | No | Track subagent usage |
|
||||
| **SubagentStop** | Subagent finishes | No | Log results, trigger follow-ups |
|
||||
| **Setup** | `--init`, `--init-only`, or `--maintenance` flags | No | Initialize environment, install dependencies |
|
||||
| **PreCompact** | Before context compacts | No | Backup conversation, preserve context |
|
||||
| **SessionStart** | Session starts/resumes | No | Load context, show status, init resources |
|
||||
| **SessionEnd** | Session ends | No | Cleanup, save state, log metrics |
|
||||
|
||||
See [references/hook-types.md](references/hook-types.md) for detailed documentation of each event.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Auto-Format TypeScript
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit(*.ts|*.tsx)",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "biome check --write \"$file\"",
|
||||
"timeout": 10
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Block Dangerous Commands
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.sh",
|
||||
"timeout": 5
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**validate-bash.sh**:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
||||
|
||||
if echo "$COMMAND" | grep -qE '\brm\s+-rf\s+/'; then
|
||||
echo "Dangerous command blocked: rm -rf /" >&2
|
||||
exit 2 # Exit 2 = block and show error to Claude
|
||||
fi
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
### Smart Validation with Prompt Hook
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{
|
||||
"type": "prompt",
|
||||
"prompt": "Analyze this file operation for safety. Check: 1) No sensitive paths (/etc, ~/.ssh), 2) No credentials in content, 3) No path traversal (..). Tool input: $TOOL_INPUT. Respond with JSON: {\"decision\": \"allow|deny\", \"reason\": \"...\"}",
|
||||
"timeout": 30
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Locations
|
||||
|
||||
| Location | Scope | Committed |
|
||||
|----------|-------|-----------|
|
||||
| `.claude/settings.json` | Project (team-shared) | Yes |
|
||||
| `.claude/settings.local.json` | Project (local only) | No |
|
||||
| `~/.claude/settings.json` | Personal (all projects) | No |
|
||||
| `plugin/hooks/hooks.json` | Plugin | Yes |
|
||||
|
||||
### Plugin Format (hooks.json)
|
||||
|
||||
Uses wrapper structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Plugin hooks for auto-formatting",
|
||||
"hooks": {
|
||||
"PostToolUse": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Format (settings.json)
|
||||
|
||||
Direct structure (no wrapper):
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Matchers
|
||||
|
||||
Matchers determine which tool invocations trigger the hook. Case-sensitive.
|
||||
|
||||
```json
|
||||
{"matcher": "Write"} // Exact match
|
||||
{"matcher": "Edit|Write"} // Multiple tools (OR)
|
||||
{"matcher": "*"} // All tools
|
||||
{"matcher": "Write(*.py)"} // File pattern
|
||||
{"matcher": "Write|Edit(*.ts|*.tsx)"} // Multiple + pattern
|
||||
{"matcher": "mcp__memory__.*"} // MCP server tools
|
||||
{"matcher": "mcp__github__create_issue"} // Specific MCP tool
|
||||
```
|
||||
|
||||
**Lifecycle hooks** (SessionStart, SessionEnd, Stop, Notification) use special matchers:
|
||||
|
||||
```json
|
||||
// SessionStart matchers
|
||||
{"matcher": "startup"} // Initial start
|
||||
{"matcher": "resume"} // --resume or --continue
|
||||
{"matcher": "clear"} // After /clear
|
||||
{"matcher": "compact"} // After compaction
|
||||
|
||||
// PreCompact matchers
|
||||
{"matcher": "manual"} // User triggered /compact
|
||||
{"matcher": "auto"} // Automatic compaction
|
||||
```
|
||||
|
||||
See [references/matchers.md](references/matchers.md) for advanced patterns.
|
||||
|
||||
## Input Format
|
||||
|
||||
All hooks receive JSON on stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "abc123",
|
||||
"transcript_path": "/path/to/transcript.jsonl",
|
||||
"cwd": "/current/working/directory",
|
||||
"hook_event_name": "PreToolUse",
|
||||
"permission_mode": "ask",
|
||||
"tool_name": "Write",
|
||||
"tool_input": {
|
||||
"file_path": "/project/src/file.ts",
|
||||
"content": "export const foo = 'bar';"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Event-specific fields**:
|
||||
- Tool hooks: `tool_name`, `tool_input`, `tool_result` (PostToolUse)
|
||||
- UserPromptSubmit: `user_prompt`
|
||||
- Stop/SubagentStop: `reason`
|
||||
|
||||
**Prompt hooks** access fields via placeholders:
|
||||
- `$ARGUMENTS` - Full context passed to the hook (general-purpose)
|
||||
- `$TOOL_INPUT` - Tool input for tool-related events
|
||||
- `$TOOL_RESULT` - Tool result (PostToolUse only)
|
||||
- `$USER_PROMPT` - User prompt (UserPromptSubmit only)
|
||||
|
||||
### Reading Input
|
||||
|
||||
**Bash**:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
```
|
||||
|
||||
**Bun/TypeScript**:
|
||||
|
||||
```typescript
|
||||
#!/usr/bin/env bun
|
||||
const input = await Bun.stdin.json();
|
||||
const toolName = input.tool_name;
|
||||
const filePath = input.tool_input?.file_path;
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
### Exit Codes (Simple)
|
||||
|
||||
```bash
|
||||
exit 0 # Success, continue execution
|
||||
exit 2 # Block operation (PreToolUse only), stderr shown to Claude
|
||||
exit 1 # Warning, stderr shown to user, continues
|
||||
```
|
||||
|
||||
### JSON Output (Advanced)
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": true,
|
||||
"suppressOutput": false,
|
||||
"systemMessage": "Context for Claude",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow|deny|ask",
|
||||
"permissionDecisionReason": "Explanation",
|
||||
"updatedInput": {"modified": "field"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PreToolUse** can modify tool input via `updatedInput` and control permissions via `permissionDecision`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Availability | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `$CLAUDE_PROJECT_DIR` | All hooks | Project root directory |
|
||||
| `$CLAUDE_PLUGIN_ROOT` | Plugin hooks | Plugin root (use for portable paths) |
|
||||
| `$file` | PostToolUse (Write/Edit) | Path to affected file |
|
||||
| `$CLAUDE_ENV_FILE` | SessionStart | Write env vars here to persist |
|
||||
| `$CLAUDE_CODE_REMOTE` | All hooks | Set if running in remote context |
|
||||
|
||||
**Plugin hooks** should always use `${CLAUDE_PLUGIN_ROOT}` for portability:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"
|
||||
}
|
||||
```
|
||||
|
||||
**SessionStart** can persist environment variables:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Persist variables for the session
|
||||
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export API_URL=https://api.example.com" >> "$CLAUDE_ENV_FILE"
|
||||
```
|
||||
|
||||
## Component-Scoped Hooks
|
||||
|
||||
Skills, agents, and commands can define hooks in frontmatter. These hooks only run when the component is active.
|
||||
|
||||
**Supported events**: PreToolUse, PostToolUse, Stop
|
||||
|
||||
### Skill with Hooks
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
description: Skill with validation hooks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Write|Edit"
|
||||
hooks:
|
||||
- type: prompt
|
||||
prompt: "Validate this write operation for the skill context..."
|
||||
---
|
||||
```
|
||||
|
||||
### Agent with Hooks
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: security-reviewer
|
||||
model: sonnet
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: "${CLAUDE_PLUGIN_ROOT}/scripts/validate-bash.sh"
|
||||
Stop:
|
||||
- matcher: "*"
|
||||
hooks:
|
||||
- type: prompt
|
||||
prompt: "Verify the security review is complete..."
|
||||
---
|
||||
```
|
||||
|
||||
## Execution Model
|
||||
|
||||
**Parallel execution**: All matching hooks run in parallel, not sequentially.
|
||||
|
||||
```json
|
||||
{
|
||||
"PreToolUse": [{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{"type": "command", "command": "check1.sh"}, // Runs in parallel
|
||||
{"type": "command", "command": "check2.sh"}, // Runs in parallel
|
||||
{"type": "prompt", "prompt": "Validate..."} // Runs in parallel
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Implications**:
|
||||
- Hooks cannot see each other's output
|
||||
- Non-deterministic ordering
|
||||
- Design for independence
|
||||
|
||||
**Hot-swap limitations**: Hook changes require restarting Claude Code. Editing `hooks.json` or hook scripts does not affect the current session.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Validate all input** - Check for path traversal, sensitive paths, injection
|
||||
2. **Quote shell variables** - Always use `"$VAR"` not `$VAR`
|
||||
3. **Set timeouts** - Prevent hanging hooks (default: 60s command, 30s prompt)
|
||||
4. **Use absolute paths** - Via `$CLAUDE_PROJECT_DIR` or `${CLAUDE_PLUGIN_ROOT}`
|
||||
5. **Handle errors gracefully** - Use `set -euo pipefail` in bash
|
||||
6. **Don't log sensitive data** - Filter credentials, tokens, API keys
|
||||
|
||||
See [references/security.md](references/security.md) for detailed security patterns.
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
# Run Claude with debug output
|
||||
claude --debug
|
||||
|
||||
# Test hook manually
|
||||
echo '{"tool_name": "Write", "tool_input": {"file_path": "test.ts"}}' | ./.claude/hooks/my-hook.sh
|
||||
|
||||
# Check transcript for hook execution
|
||||
# Press Ctrl+R in Claude Code to view transcript
|
||||
```
|
||||
|
||||
**Common issues**:
|
||||
- Hook not firing: Check matcher syntax, restart Claude Code
|
||||
- Permission errors: `chmod +x script.sh`
|
||||
- Timeout: Increase timeout value or optimize script
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### Pre-Commit Quality Gate
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{"type": "command", "command": "./.claude/hooks/validate-paths.sh"},
|
||||
{"type": "command", "command": "./.claude/hooks/check-sensitive.sh"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit(*.ts)",
|
||||
"hooks": [
|
||||
{"type": "command", "command": "biome check --write \"$file\""},
|
||||
{"type": "command", "command": "tsc --noEmit \"$file\""}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context Injection
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [{
|
||||
"matcher": "startup",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo \"Branch: $(git branch --show-current)\" && git status --short"
|
||||
}]
|
||||
}],
|
||||
"UserPromptSubmit": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo \"Time: $(date '+%Y-%m-%d %H:%M %Z')\""
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [references/hook-types.md](references/hook-types.md) - Detailed documentation for each hook event
|
||||
- [references/matchers.md](references/matchers.md) - Advanced matcher patterns and MCP tools
|
||||
- [references/security.md](references/security.md) - Security best practices and validation patterns
|
||||
- [references/schema.md](references/schema.md) - Complete configuration schema reference
|
||||
- [references/examples.md](references/examples.md) - Real-world hook implementations
|
||||
|
||||
## External Resources
|
||||
|
||||
- [Official Hooks Reference](https://code.claude.com/docs/en/hooks)
|
||||
- [Hooks Guide](https://code.claude.com/docs/en/hooks-guide)
|
||||
- [Community Examples (disler)](https://github.com/disler/claude-code-hooks-mastery)
|
||||
- [Claude Code Showcase](https://github.com/ChrisWiles/claude-code-showcase)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
# Hook Types Reference
|
||||
|
||||
Detailed documentation for each Claude Code hook event.
|
||||
|
||||
## Tool Hooks
|
||||
|
||||
### PreToolUse
|
||||
|
||||
Executes **before** a tool runs. Can block or modify tool execution.
|
||||
|
||||
**Timing**: After Claude creates tool parameters, before tool executes
|
||||
|
||||
**Can block**: Yes (exit code 2 or `permissionDecision: "deny"`)
|
||||
|
||||
**Supports**: Both `command` and `prompt` hook types
|
||||
|
||||
**Input fields**:
|
||||
- `tool_name`: Name of the tool being called
|
||||
- `tool_input`: Parameters being passed to the tool
|
||||
|
||||
**Output capabilities**:
|
||||
- Block execution with exit code 2 or `permissionDecision: "deny"`
|
||||
- Modify input with `updatedInput` in JSON response
|
||||
- Ask user with `permissionDecision: "ask"`
|
||||
- Provide context via `systemMessage`
|
||||
|
||||
**Common matchers**:
|
||||
|
||||
```json
|
||||
"Bash" // Shell commands
|
||||
"Write" // File writing
|
||||
"Edit" // File editing
|
||||
"Read" // File reading
|
||||
"Write|Edit" // Multiple tools
|
||||
"Write(*.py)" // File patterns
|
||||
"mcp__memory__.*" // MCP tools
|
||||
"*" // All tools
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Validate bash commands before execution
|
||||
- Check file paths for security issues
|
||||
- Block dangerous operations
|
||||
- Add context before execution
|
||||
- Enforce security policies
|
||||
- Log tool invocations
|
||||
- Modify tool input on the fly
|
||||
|
||||
**Example - Block dangerous commands**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/validate-bash.sh",
|
||||
"timeout": 5
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Smart validation with prompt**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PreToolUse": [{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{
|
||||
"type": "prompt",
|
||||
"prompt": "Analyze this file operation. Check for: 1) sensitive paths, 2) credentials in content, 3) path traversal. Tool: $TOOL_INPUT. Return {\"decision\": \"allow|deny\", \"reason\": \"...\"}",
|
||||
"timeout": 30
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Modify tool input**:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Add timestamp to all file writes
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
|
||||
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content')
|
||||
|
||||
# Add header to content
|
||||
NEW_CONTENT="// Modified $(date -Iseconds)\n$CONTENT"
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow",
|
||||
"updatedInput": {
|
||||
"file_path": "$FILE_PATH",
|
||||
"content": "$NEW_CONTENT"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
### PostToolUse
|
||||
|
||||
Executes **after** a tool completes successfully.
|
||||
|
||||
**Timing**: Immediately after tool returns success
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type only
|
||||
|
||||
**Input fields**:
|
||||
- `tool_name`: Name of the tool that ran
|
||||
- `tool_input`: Parameters that were passed
|
||||
- `tool_result`: Result returned by the tool
|
||||
|
||||
**Special variables**:
|
||||
- `$file`: Path to affected file (Write/Edit tools only)
|
||||
|
||||
**Common matchers**:
|
||||
|
||||
```json
|
||||
"Write|Edit(*.ts)" // TypeScript files
|
||||
"Write(*.py)" // Python files
|
||||
"Write|Edit" // Any file modification
|
||||
"*" // All successful tools
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Auto-format code files
|
||||
- Run linters
|
||||
- Update documentation
|
||||
- Trigger builds
|
||||
- Send notifications
|
||||
- Update indexes
|
||||
|
||||
**Example - Auto-format TypeScript**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PostToolUse": [{
|
||||
"matcher": "Write|Edit(*.ts|*.tsx)",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "biome check --write \"$file\"",
|
||||
"timeout": 10
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Chain multiple formatters**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PostToolUse": [{
|
||||
"matcher": "Write|Edit(*.py)",
|
||||
"hooks": [
|
||||
{"type": "command", "command": "black \"$file\"", "timeout": 10},
|
||||
{"type": "command", "command": "isort \"$file\"", "timeout": 5},
|
||||
{"type": "command", "command": "mypy \"$file\"", "timeout": 15}
|
||||
]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### PostToolUseFailure
|
||||
|
||||
Executes **after** a tool fails.
|
||||
|
||||
**Timing**: After tool execution fails
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- `tool_name`: Name of the tool that failed
|
||||
- `tool_input`: Parameters that were passed
|
||||
- `error`: Error information
|
||||
|
||||
**Use cases**:
|
||||
- Error logging and analytics
|
||||
- Retry logic
|
||||
- Failure notifications
|
||||
- Error recovery
|
||||
- Debug information collection
|
||||
|
||||
**Example - Log failures**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PostToolUseFailure": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/log-failure.sh",
|
||||
"timeout": 5
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### PermissionRequest
|
||||
|
||||
Executes when a permission dialog would be shown to the user.
|
||||
|
||||
**Timing**: Before showing permission dialog
|
||||
|
||||
**Can block**: Yes (via `permissionDecision`)
|
||||
|
||||
**Supports**: Both `command` and `prompt` hook types
|
||||
|
||||
**Input fields**:
|
||||
- `tool_name`: Tool requesting permission
|
||||
- `tool_input`: Parameters being requested
|
||||
|
||||
**Output capabilities**:
|
||||
- Auto-allow with `permissionDecision: "allow"`
|
||||
- Auto-deny with `permissionDecision: "deny"`
|
||||
- Show dialog with `permissionDecision: "ask"` (default)
|
||||
|
||||
**Use cases**:
|
||||
- Auto-approve known-safe operations
|
||||
- Auto-deny high-risk operations
|
||||
- Implement custom permission policies
|
||||
- Reduce permission fatigue for trusted patterns
|
||||
|
||||
**Example - Auto-approve safe reads**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PermissionRequest": [{
|
||||
"matcher": "Read",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/auto-approve-reads.sh",
|
||||
"timeout": 3
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## User Interaction Hooks
|
||||
|
||||
### UserPromptSubmit
|
||||
|
||||
Executes when user submits a prompt to Claude.
|
||||
|
||||
**Timing**: After user submits, before Claude processes
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: Both `command` and `prompt` hook types
|
||||
|
||||
**Input fields**:
|
||||
- `user_prompt`: The prompt text submitted
|
||||
|
||||
**Matcher**: Always `*`
|
||||
|
||||
**Use cases**:
|
||||
- Add timestamp or date context
|
||||
- Add environment information
|
||||
- Log user activity
|
||||
- Pre-process or augment prompts
|
||||
- Add project context
|
||||
- Skill matching and suggestion
|
||||
|
||||
**Example - Add timestamp**:
|
||||
|
||||
```json
|
||||
{
|
||||
"UserPromptSubmit": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo \"Current time: $(date '+%Y-%m-%d %H:%M:%S %Z')\"",
|
||||
"timeout": 2
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Add git context**:
|
||||
|
||||
```json
|
||||
{
|
||||
"UserPromptSubmit": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo \"Branch: $(git branch --show-current 2>/dev/null || echo 'N/A')\"",
|
||||
"timeout": 3
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Notification
|
||||
|
||||
Executes when Claude Code sends a notification.
|
||||
|
||||
**Timing**: When notification is triggered
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- Notification message and metadata
|
||||
|
||||
**Matcher**: Always `*`
|
||||
|
||||
**Use cases**:
|
||||
- Send to external systems (Slack, email)
|
||||
- Log notifications
|
||||
- Trigger alerts
|
||||
- Update dashboards
|
||||
- Archive important messages
|
||||
- Text-to-speech announcements
|
||||
|
||||
**Example - Slack integration**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Notification": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/send-to-slack.sh",
|
||||
"timeout": 10
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Lifecycle Hooks
|
||||
|
||||
### Stop
|
||||
|
||||
Executes when main Claude agent finishes responding.
|
||||
|
||||
**Timing**: After Claude completes response
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: Both `command` and `prompt` hook types
|
||||
|
||||
**Input fields**:
|
||||
- `reason`: Why the agent stopped
|
||||
|
||||
**Matcher**: Always `*`
|
||||
|
||||
**Use cases**:
|
||||
- Clean up temporary resources
|
||||
- Send completion notifications
|
||||
- Update external systems
|
||||
- Log session metrics
|
||||
- Archive conversation
|
||||
- Verify task completion
|
||||
|
||||
**Example - Completion notification**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Stop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo 'Task completed at $(date +%H:%M)'",
|
||||
"timeout": 2
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Verify completeness with prompt**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Stop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "prompt",
|
||||
"prompt": "Review if the task was completed satisfactorily. Check for any unfinished work or follow-up items.",
|
||||
"timeout": 30
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### SubagentStart
|
||||
|
||||
Executes when a subagent (Task tool) spawns.
|
||||
|
||||
**Timing**: When subagent is created
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- Subagent metadata
|
||||
|
||||
**Matcher**: Always `*`
|
||||
|
||||
**Use cases**:
|
||||
- Track subagent spawning
|
||||
- Log subagent parameters
|
||||
- Monitor parallel execution
|
||||
- Resource allocation
|
||||
|
||||
**Example - Track subagent usage**:
|
||||
|
||||
```json
|
||||
{
|
||||
"SubagentStart": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/log-subagent-start.sh",
|
||||
"timeout": 2
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### SubagentStop
|
||||
|
||||
Executes when a subagent (Task tool) finishes.
|
||||
|
||||
**Timing**: After subagent completes
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: Both `command` and `prompt` hook types
|
||||
|
||||
**Input fields**:
|
||||
- `reason`: Why the subagent stopped
|
||||
- Subagent result metadata
|
||||
|
||||
**Matcher**: Always `*`
|
||||
|
||||
**Use cases**:
|
||||
- Track subagent completion
|
||||
- Log subagent results
|
||||
- Trigger follow-up actions
|
||||
- Update metrics
|
||||
- Debug subagent behavior
|
||||
|
||||
**Example - Log subagent completion**:
|
||||
|
||||
```json
|
||||
{
|
||||
"SubagentStop": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/log-subagent-stop.sh",
|
||||
"timeout": 3
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Session Lifecycle Hooks
|
||||
|
||||
### SessionStart
|
||||
|
||||
Executes when session starts or resumes.
|
||||
|
||||
**Timing**: At session initialization
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- `reason`: Start type
|
||||
|
||||
**Matchers**:
|
||||
|
||||
```json
|
||||
"startup" // Claude Code starts fresh
|
||||
"resume" // Session resumes (--resume or --continue)
|
||||
"clear" // After /clear command
|
||||
"compact" // After compaction
|
||||
```
|
||||
|
||||
**Special capability**: Persist environment variables via `$CLAUDE_ENV_FILE`
|
||||
|
||||
**Use cases**:
|
||||
- Display welcome message
|
||||
- Show git status
|
||||
- Load project context
|
||||
- Check for updates
|
||||
- Initialize resources
|
||||
- Set session-wide variables
|
||||
|
||||
**Example - Welcome with git status**:
|
||||
|
||||
```json
|
||||
{
|
||||
"SessionStart": [{
|
||||
"matcher": "startup",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo 'Welcome!' && git status --short",
|
||||
"timeout": 5
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Example - Persist environment variables**:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# This script runs on SessionStart
|
||||
# Persist variables for the entire session
|
||||
|
||||
# Detect project type and persist
|
||||
if [[ -f "package.json" ]]; then
|
||||
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
|
||||
elif [[ -f "Cargo.toml" ]]; then
|
||||
echo "export PROJECT_TYPE=rust" >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
|
||||
# Set API endpoints
|
||||
echo "export API_URL=https://api.example.com" >> "$CLAUDE_ENV_FILE"
|
||||
```
|
||||
|
||||
### SessionEnd
|
||||
|
||||
Executes when session ends.
|
||||
|
||||
**Timing**: Before session terminates
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- `reason`: End type
|
||||
|
||||
**Matchers** (reasons):
|
||||
|
||||
```json
|
||||
"clear" // User ran /clear
|
||||
"logout" // User logged out
|
||||
"prompt_input_exit" // Exited during prompt input
|
||||
"other" // Other reasons
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Clean up resources
|
||||
- Save state
|
||||
- Log session metrics
|
||||
- Send completion notifications
|
||||
- Archive transcripts
|
||||
|
||||
**Example - Cleanup**:
|
||||
|
||||
```json
|
||||
{
|
||||
"SessionEnd": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/cleanup.sh",
|
||||
"timeout": 5
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### PreCompact
|
||||
|
||||
Executes before conversation compacts.
|
||||
|
||||
**Timing**: Before compact operation starts
|
||||
|
||||
**Can block**: No
|
||||
|
||||
**Supports**: `command` hook type
|
||||
|
||||
**Input fields**:
|
||||
- Compact trigger type
|
||||
|
||||
**Matchers**:
|
||||
|
||||
```json
|
||||
"manual" // User triggered via /compact
|
||||
"auto" // Automatic compact (context limit)
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Backup conversation
|
||||
- Archive important context
|
||||
- Update external summaries
|
||||
- Log compact events
|
||||
- Prepare for context reset
|
||||
|
||||
**Example - Backup before compact**:
|
||||
|
||||
```json
|
||||
{
|
||||
"PreCompact": [{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/backup-conversation.sh",
|
||||
"timeout": 10
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Hook Type Comparison
|
||||
|
||||
| Event | Can Block | Prompt Type | Command Type | Common Use |
|
||||
|-------|-----------|-------------|--------------|------------|
|
||||
| PreToolUse | Yes | Yes | Yes | Validation, security |
|
||||
| PostToolUse | No | No | Yes | Formatting, linting |
|
||||
| PostToolUseFailure | No | No | Yes | Error logging |
|
||||
| PermissionRequest | Yes | Yes | Yes | Auto-approve/deny |
|
||||
| UserPromptSubmit | No | Yes | Yes | Context injection |
|
||||
| Notification | No | No | Yes | External alerts |
|
||||
| Stop | No | Yes | Yes | Cleanup, verification |
|
||||
| SubagentStart | No | No | Yes | Tracking |
|
||||
| SubagentStop | No | Yes | Yes | Logging |
|
||||
| SessionStart | No | No | Yes | Initialization |
|
||||
| SessionEnd | No | No | Yes | Cleanup |
|
||||
| PreCompact | No | No | Yes | Backup |
|
||||
|
||||
## Tool Use ID Correlation
|
||||
|
||||
PreToolUse and PostToolUse events for the same tool invocation share a tool use ID, allowing you to correlate them:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse - save state
|
||||
INPUT=$(cat)
|
||||
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id')
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
|
||||
# Save start time for correlation
|
||||
echo "$(date +%s%N)" > "/tmp/claude-tool-$TOOL_USE_ID.start"
|
||||
```
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse - calculate duration
|
||||
INPUT=$(cat)
|
||||
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id')
|
||||
|
||||
START=$(cat "/tmp/claude-tool-$TOOL_USE_ID.start" 2>/dev/null || echo "0")
|
||||
END=$(date +%s%N)
|
||||
DURATION_MS=$(( (END - START) / 1000000 ))
|
||||
|
||||
echo "Tool completed in ${DURATION_MS}ms"
|
||||
rm -f "/tmp/claude-tool-$TOOL_USE_ID.start"
|
||||
```
|
||||
@@ -0,0 +1,302 @@
|
||||
# Matcher Patterns Reference
|
||||
|
||||
Matchers determine which tool invocations or events trigger a hook. They are case-sensitive strings that support exact matching, regex patterns, wildcards, and file patterns.
|
||||
|
||||
## Matcher Types
|
||||
|
||||
### Simple String Match
|
||||
|
||||
Match exact tool name:
|
||||
|
||||
```json
|
||||
{"matcher": "Write"} // Only Write tool
|
||||
{"matcher": "Edit"} // Only Edit tool
|
||||
{"matcher": "Bash"} // Only Bash tool
|
||||
{"matcher": "Read"} // Only Read tool
|
||||
{"matcher": "Grep"} // Only Grep tool
|
||||
{"matcher": "Glob"} // Only Glob tool
|
||||
{"matcher": "Task"} // Only Task tool (subagents)
|
||||
{"matcher": "WebFetch"} // Only WebFetch tool
|
||||
{"matcher": "WebSearch"}// Only WebSearch tool
|
||||
```
|
||||
|
||||
### OR Patterns (Pipe)
|
||||
|
||||
Match multiple tools with `|`:
|
||||
|
||||
```json
|
||||
{"matcher": "Edit|Write"} // Edit OR Write
|
||||
{"matcher": "Read|Grep|Glob"} // Any read/search operation
|
||||
{"matcher": "Write|Edit|NotebookEdit"} // Multiple specific tools
|
||||
{"matcher": "WebFetch|WebSearch"} // Web operations
|
||||
```
|
||||
|
||||
### Wildcard Match
|
||||
|
||||
Match all tools with `*`:
|
||||
|
||||
```json
|
||||
{"matcher": "*"} // Matches everything
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- Logging all tool usage
|
||||
- Global validation
|
||||
- Universal context injection
|
||||
- Metrics collection
|
||||
|
||||
### File Pattern Match
|
||||
|
||||
Match tools operating on specific file types with `(pattern)`:
|
||||
|
||||
```json
|
||||
{"matcher": "Write(*.py)"} // Write Python files
|
||||
{"matcher": "Edit(*.ts)"} // Edit TypeScript files
|
||||
{"matcher": "Write(*.md)"} // Write Markdown files
|
||||
{"matcher": "Write|Edit(*.js)"} // Write or Edit JavaScript
|
||||
{"matcher": "Write|Edit(*.ts|*.tsx)"} // TypeScript and TSX files
|
||||
```
|
||||
|
||||
**Supported patterns**:
|
||||
- `*.ext` - Any file with extension
|
||||
- `path/*.ext` - Files in specific directory (relative to project)
|
||||
- `**/*.ext` - Recursive file match
|
||||
|
||||
**More examples**:
|
||||
|
||||
```json
|
||||
{"matcher": "Write(*.tsx)"} // React components
|
||||
{"matcher": "Write|Edit(*.rs)"} // Rust files
|
||||
{"matcher": "Write(src/**/*.ts)"} // TS files in src/
|
||||
{"matcher": "Edit(.env*)"} // .env files
|
||||
{"matcher": "Write(*.json)"} // JSON files
|
||||
{"matcher": "Write|Edit(*.yaml|*.yml)"} // YAML files
|
||||
```
|
||||
|
||||
### Regex Patterns
|
||||
|
||||
Full regex support for complex matching:
|
||||
|
||||
```json
|
||||
{"matcher": "^Write$"} // Exactly "Write", no prefix/suffix
|
||||
{"matcher": ".*Edit.*"} // Contains "Edit" anywhere
|
||||
{"matcher": "Notebook.*"} // Starts with "Notebook"
|
||||
{"matcher": "Bash|WebFetch"} // Bash or WebFetch
|
||||
```
|
||||
|
||||
**Regex features**:
|
||||
- `|` - OR operator
|
||||
- `.` - Any character
|
||||
- `*` - Zero or more
|
||||
- `+` - One or more
|
||||
- `^` - Start of string
|
||||
- `$` - End of string
|
||||
- `[abc]` - Character class
|
||||
- `\w` - Word character
|
||||
- `\d` - Digit
|
||||
|
||||
## MCP Tool Matchers
|
||||
|
||||
MCP (Model Context Protocol) tools follow naming: `mcp__<server-name>__<tool-name>`
|
||||
|
||||
### Match All MCP Tools
|
||||
|
||||
```json
|
||||
{"matcher": "mcp__.*__.*"} // Any MCP tool from any server
|
||||
```
|
||||
|
||||
### Match Specific Server
|
||||
|
||||
```json
|
||||
{"matcher": "mcp__memory__.*"} // All memory MCP tools
|
||||
{"matcher": "mcp__github__.*"} // All GitHub MCP tools
|
||||
{"matcher": "mcp__filesystem__.*"} // All filesystem MCP tools
|
||||
{"matcher": "mcp__brave-search__.*"}// All Brave search tools
|
||||
```
|
||||
|
||||
### Match Specific Tools
|
||||
|
||||
```json
|
||||
{"matcher": "mcp__github__create_issue"} // Specific GitHub tool
|
||||
{"matcher": "mcp__github__create_pull_request"} // Create PR tool
|
||||
{"matcher": "mcp__memory__add_memory"} // Add to memory
|
||||
{"matcher": "mcp__memory__search_memory"} // Search memory
|
||||
```
|
||||
|
||||
### Complex MCP Patterns
|
||||
|
||||
```json
|
||||
// All delete operations across MCP servers
|
||||
{"matcher": "mcp__.*__delete.*"}
|
||||
|
||||
// Create operations in GitHub
|
||||
{"matcher": "mcp__github__(create_issue|create_comment|create_pull_request)"}
|
||||
|
||||
// All read operations in filesystem
|
||||
{"matcher": "mcp__filesystem__(read|list|search).*"}
|
||||
|
||||
// Dangerous operations to block
|
||||
{"matcher": "mcp__.*(delete|remove|destroy).*"}
|
||||
```
|
||||
|
||||
## Lifecycle Event Matchers
|
||||
|
||||
Some hooks use special matchers for lifecycle events instead of tool names.
|
||||
|
||||
### SessionStart Matchers
|
||||
|
||||
```json
|
||||
{"matcher": "startup"} // Fresh Claude Code start
|
||||
{"matcher": "resume"} // Session resume (--resume, --continue)
|
||||
{"matcher": "clear"} // After /clear command
|
||||
{"matcher": "compact"} // After context compaction
|
||||
{"matcher": "*"} // Any session start type
|
||||
```
|
||||
|
||||
### SessionEnd Matchers
|
||||
|
||||
```json
|
||||
{"matcher": "clear"} // User ran /clear
|
||||
{"matcher": "logout"} // User logged out
|
||||
{"matcher": "prompt_input_exit"} // Exited during prompt
|
||||
{"matcher": "other"} // Other reasons
|
||||
{"matcher": "*"} // Any end reason
|
||||
```
|
||||
|
||||
### PreCompact Matchers
|
||||
|
||||
```json
|
||||
{"matcher": "manual"} // User triggered /compact
|
||||
{"matcher": "auto"} // Automatic compaction
|
||||
{"matcher": "*"} // Any compact type
|
||||
```
|
||||
|
||||
### Stop/SubagentStop Matchers
|
||||
|
||||
```json
|
||||
{"matcher": "*"} // Always matches (lifecycle events)
|
||||
```
|
||||
|
||||
## Complex Matcher Examples
|
||||
|
||||
### Multiple Tools with File Patterns
|
||||
|
||||
```json
|
||||
// Format Python or TypeScript
|
||||
{"matcher": "Write|Edit(*.py)|Write|Edit(*.ts)"}
|
||||
|
||||
// All code files
|
||||
{"matcher": "Write|Edit(*.ts|*.tsx|*.js|*.jsx|*.py|*.rs)"}
|
||||
```
|
||||
|
||||
### Excluding Patterns
|
||||
|
||||
There's no direct exclusion, but you can handle this in the hook script:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Skip test files
|
||||
if [[ "$FILE_PATH" =~ \.(test|spec)\. ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip node_modules
|
||||
if [[ "$FILE_PATH" =~ node_modules/ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Continue with validation...
|
||||
```
|
||||
|
||||
### Combining with Regex
|
||||
|
||||
```json
|
||||
// Bash or any MCP tool
|
||||
{"matcher": "Bash|mcp__.*__.*"}
|
||||
|
||||
// Read operations (multiple tools)
|
||||
{"matcher": "Read|Grep|Glob|WebFetch"}
|
||||
|
||||
// File modifications only
|
||||
{"matcher": "Write|Edit|NotebookEdit"}
|
||||
```
|
||||
|
||||
## Matcher Debugging
|
||||
|
||||
If your hook isn't firing, check:
|
||||
|
||||
1. **Case sensitivity**: `Write` works, `write` doesn't
|
||||
2. **Exact tool names**: Use `claude --debug` to see actual tool names
|
||||
3. **File patterns**: Ensure the pattern matches the file path format
|
||||
4. **MCP naming**: Verify server and tool names match exactly
|
||||
|
||||
### Testing Matchers
|
||||
|
||||
```bash
|
||||
# See what tools Claude is calling
|
||||
claude --debug 2>&1 | grep "tool_name"
|
||||
|
||||
# Test regex patterns
|
||||
echo "Write" | grep -E '^Write$' # Should match
|
||||
echo "WriteFile" | grep -E '^Write$' # Should not match
|
||||
```
|
||||
|
||||
## Common Matcher Patterns
|
||||
|
||||
### Security Validation
|
||||
|
||||
```json
|
||||
// All file operations
|
||||
{"matcher": "Write|Edit|Read"}
|
||||
|
||||
// Dangerous commands
|
||||
{"matcher": "Bash"}
|
||||
|
||||
// Network operations
|
||||
{"matcher": "WebFetch|WebSearch|mcp__.*"}
|
||||
```
|
||||
|
||||
### Auto-Formatting
|
||||
|
||||
```json
|
||||
// TypeScript/JavaScript
|
||||
{"matcher": "Write|Edit(*.ts|*.tsx|*.js|*.jsx)"}
|
||||
|
||||
// Python
|
||||
{"matcher": "Write|Edit(*.py)"}
|
||||
|
||||
// Rust
|
||||
{"matcher": "Write|Edit(*.rs)"}
|
||||
|
||||
// All supported
|
||||
{"matcher": "Write|Edit(*.ts|*.tsx|*.py|*.rs|*.go)"}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```json
|
||||
// All tool operations
|
||||
{"matcher": "*"}
|
||||
|
||||
// All MCP operations
|
||||
{"matcher": "mcp__.*__.*"}
|
||||
|
||||
// File operations only
|
||||
{"matcher": "Write|Edit|Read|Grep|Glob"}
|
||||
```
|
||||
|
||||
### External Integration
|
||||
|
||||
```json
|
||||
// GitHub operations
|
||||
{"matcher": "mcp__github__.*"}
|
||||
|
||||
// Memory operations
|
||||
{"matcher": "mcp__memory__.*"}
|
||||
|
||||
// All external services
|
||||
{"matcher": "mcp__.*__.*|WebFetch|WebSearch"}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,533 @@
|
||||
# Security Best Practices
|
||||
|
||||
Comprehensive security guidance for Claude Code hooks.
|
||||
|
||||
## Input Validation
|
||||
|
||||
### Validate All Input
|
||||
|
||||
Always validate and sanitize hook input before use:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Validate input exists
|
||||
if [[ -z "$FILE_PATH" ]]; then
|
||||
echo "Error: file_path missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate format
|
||||
if [[ ! "$FILE_PATH" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
|
||||
echo "Error: invalid characters in file path" >&2
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
### Check for Path Traversal
|
||||
|
||||
Block directory traversal attacks:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Block path traversal
|
||||
if echo "$FILE_PATH" | grep -qE '\.\./'; then
|
||||
cat << EOF >&2
|
||||
Path traversal detected: $FILE_PATH
|
||||
Paths containing '..' are not allowed.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Block absolute paths outside project
|
||||
if [[ "$FILE_PATH" == /* ]] && [[ ! "$FILE_PATH" == "$CLAUDE_PROJECT_DIR"* ]]; then
|
||||
echo "Access outside project directory blocked: $FILE_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
### Block Sensitive System Paths
|
||||
|
||||
Prevent access to system files:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Blocked system paths
|
||||
BLOCKED_PATHS=(
|
||||
'^/etc/'
|
||||
'^/root/'
|
||||
'^/home/[^/]+/\.ssh/'
|
||||
'^/var/log/'
|
||||
'^/sys/'
|
||||
'^/proc/'
|
||||
'^/boot/'
|
||||
'^/usr/bin/'
|
||||
'^/usr/sbin/'
|
||||
)
|
||||
|
||||
for pattern in "${BLOCKED_PATHS[@]}"; do
|
||||
if echo "$FILE_PATH" | grep -qE "$pattern"; then
|
||||
cat << EOF >&2
|
||||
Access to sensitive system path blocked: $FILE_PATH
|
||||
This path is restricted for security reasons.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Detect Sensitive Files
|
||||
|
||||
Warn or block access to sensitive project files:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Sensitive file patterns
|
||||
SENSITIVE_PATTERNS=(
|
||||
'\.env$'
|
||||
'\.env\.'
|
||||
'id_rsa'
|
||||
'id_ed25519'
|
||||
'\.pem$'
|
||||
'\.key$'
|
||||
'\.p12$'
|
||||
'credentials'
|
||||
'password'
|
||||
'token'
|
||||
'secret'
|
||||
'\.git/config$'
|
||||
'\.npmrc$'
|
||||
'\.pypirc$'
|
||||
)
|
||||
|
||||
for pattern in "${SENSITIVE_PATTERNS[@]}"; do
|
||||
if echo "$FILE_PATH" | grep -qiE "$pattern"; then
|
||||
cat << EOF >&2
|
||||
Warning: Accessing sensitive file: $FILE_PATH
|
||||
This file may contain sensitive information.
|
||||
EOF
|
||||
# Could exit 2 to block, or continue with warning
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Command Injection Prevention
|
||||
|
||||
### Always Quote Variables
|
||||
|
||||
```bash
|
||||
# WRONG - vulnerable to injection
|
||||
rm $FILE_PATH
|
||||
cd $DIRECTORY
|
||||
echo $CONTENT
|
||||
|
||||
# CORRECT - properly quoted
|
||||
rm "$FILE_PATH"
|
||||
cd "$DIRECTORY"
|
||||
echo "$CONTENT"
|
||||
```
|
||||
|
||||
### Avoid eval
|
||||
|
||||
```bash
|
||||
# WRONG - dangerous
|
||||
eval "$USER_COMMAND"
|
||||
|
||||
# CORRECT - use specific commands
|
||||
if [[ "$USER_COMMAND" == "format" ]]; then
|
||||
black "$FILE_PATH"
|
||||
fi
|
||||
```
|
||||
|
||||
### Validate Command Patterns
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
||||
|
||||
# Block dangerous command patterns
|
||||
DANGEROUS_PATTERNS=(
|
||||
'\brm\s+-rf\s+/' # rm -rf /
|
||||
'\brm\s+--no-preserve-root' # rm --no-preserve-root
|
||||
'\bmkfs\b' # filesystem format
|
||||
'\bdd\s+if=' # disk destruction
|
||||
'\bformat\s+[cC]:' # Windows format
|
||||
'>\s*/dev/sd[a-z]' # overwrite disk
|
||||
':()\{\s*:\|\:&\s*\};:' # Fork bomb
|
||||
'\bchmod\s+777\s+/' # Dangerous permissions
|
||||
'\bchown\s+.*\s+/' # System ownership change
|
||||
'\bcurl\s+.*\|\s*bash' # Pipe to bash
|
||||
'\bwget\s+.*\|\s*bash' # Pipe to bash
|
||||
'\bsudo\s+rm' # Sudo rm
|
||||
'\bgit\s+push\s+--force\s+origin\s+main' # Force push main
|
||||
)
|
||||
|
||||
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
||||
if echo "$COMMAND" | grep -qE "$pattern"; then
|
||||
cat << EOF >&2
|
||||
Dangerous command blocked: $COMMAND
|
||||
Pattern matched: $pattern
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Path Security
|
||||
|
||||
### Use Absolute Paths
|
||||
|
||||
Always construct paths from known roots:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Use CLAUDE_PROJECT_DIR for project paths
|
||||
SCRIPT_PATH="$CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh"
|
||||
|
||||
# Use CLAUDE_PLUGIN_ROOT for plugin paths
|
||||
PLUGIN_SCRIPT="${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"
|
||||
|
||||
# Never rely on relative paths
|
||||
# BAD: ./scripts/validate.sh
|
||||
# GOOD: "$CLAUDE_PROJECT_DIR/.claude/scripts/validate.sh"
|
||||
```
|
||||
|
||||
### Validate Script Existence
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_PATH="$CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh"
|
||||
|
||||
# Check exists
|
||||
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
||||
echo "Error: script not found: $SCRIPT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check executable
|
||||
if [[ ! -x "$SCRIPT_PATH" ]]; then
|
||||
echo "Error: script not executable: $SCRIPT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Execute safely
|
||||
"$SCRIPT_PATH" "$@"
|
||||
```
|
||||
|
||||
### Resolve Symlinks
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
|
||||
|
||||
# Resolve symlinks to check actual destination
|
||||
REAL_PATH=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
|
||||
|
||||
# Check the real path is within project
|
||||
if [[ ! "$REAL_PATH" == "$CLAUDE_PROJECT_DIR"* ]]; then
|
||||
echo "Symlink points outside project: $FILE_PATH -> $REAL_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
## Sensitive Data Protection
|
||||
|
||||
### Never Log Sensitive Data
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
INPUT=$(cat)
|
||||
|
||||
# WRONG - logs everything including secrets
|
||||
echo "Input: $INPUT" >> /tmp/debug.log
|
||||
|
||||
# CORRECT - log only safe fields
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
echo "Tool: $TOOL_NAME" >> /tmp/debug.log
|
||||
|
||||
# CORRECT - filter sensitive fields before logging
|
||||
echo "$INPUT" | jq 'del(.tool_input.password, .tool_input.api_key, .tool_input.token)' >> /tmp/debug.log
|
||||
```
|
||||
|
||||
### Sanitize Output
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
INPUT=$(cat)
|
||||
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
|
||||
|
||||
# Check for secrets in content
|
||||
if echo "$CONTENT" | grep -qiE '(password|api_key|secret|token)\s*[=:]\s*\S+'; then
|
||||
echo "Warning: Potential secret detected in content" >&2
|
||||
# Could block or just warn
|
||||
fi
|
||||
```
|
||||
|
||||
### Protect Environment Variables
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Don't expose sensitive env vars
|
||||
|
||||
# WRONG
|
||||
echo "API_KEY=$API_KEY"
|
||||
env | grep -i secret
|
||||
|
||||
# CORRECT - never print secrets
|
||||
echo "API key configured: $([ -n "$API_KEY" ] && echo "yes" || echo "no")"
|
||||
```
|
||||
|
||||
## Timeout Protection
|
||||
|
||||
### Set Appropriate Timeouts
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/validate.sh",
|
||||
"timeout": 5
|
||||
}]
|
||||
}],
|
||||
"PostToolUse": [{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "./.claude/hooks/format.sh",
|
||||
"timeout": 30
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines**:
|
||||
- Quick validation: 3-5 seconds
|
||||
- Formatting: 10-30 seconds
|
||||
- Network operations: 30-60 seconds
|
||||
- Default: 60 seconds for command, 30 seconds for prompt
|
||||
|
||||
### Handle Timeouts Gracefully
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Set internal timeout for network operations
|
||||
timeout 10 curl -s https://api.example.com/validate || {
|
||||
echo "API validation skipped (timeout)" >&2
|
||||
exit 0 # Don't block on timeout
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Use Strict Mode
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail # Exit on error, undefined vars, pipe failures
|
||||
|
||||
# Also consider:
|
||||
set -E # Inherit ERR trap in functions
|
||||
trap 'echo "Error on line $LINENO" >&2' ERR
|
||||
```
|
||||
|
||||
### Validate Dependencies
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check required tools exist
|
||||
for cmd in jq git curl; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Error: $cmd not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Handle JSON Parsing Errors
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Read input with error handling
|
||||
INPUT=$(cat) || {
|
||||
echo "Error: failed to read stdin" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse with validation
|
||||
if ! echo "$INPUT" | jq empty 2>/dev/null; then
|
||||
echo "Error: invalid JSON input" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
||||
if [[ -z "$TOOL_NAME" ]]; then
|
||||
echo "Error: tool_name missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Permission Control
|
||||
|
||||
### PreToolUse Permission Decisions
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Auto-approve reads of non-sensitive files
|
||||
if [[ "$TOOL_NAME" == "Read" ]] && [[ ! "$FILE_PATH" =~ \.(env|key|pem)$ ]]; then
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ask for writes to core files
|
||||
if [[ "$FILE_PATH" =~ src/core/ ]]; then
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "ask",
|
||||
"permissionDecisionReason": "Write to core module requires confirmation"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Default: allow
|
||||
exit 0
|
||||
```
|
||||
|
||||
### PermissionRequest Hook
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
|
||||
# Auto-deny certain operations
|
||||
if [[ "$TOOL_NAME" =~ (delete|destroy|remove) ]]; then
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Destructive operations require manual approval"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
## Audit Trail
|
||||
|
||||
### Log All Operations
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AUDIT_FILE="$CLAUDE_PROJECT_DIR/.claude/audit.log"
|
||||
|
||||
INPUT=$(cat)
|
||||
TIMESTAMP=$(date -Iseconds)
|
||||
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "N/A"')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // "N/A"')
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
|
||||
|
||||
# Create audit entry (filter sensitive data)
|
||||
AUDIT_ENTRY=$(jq -n \
|
||||
--arg ts "$TIMESTAMP" \
|
||||
--arg event "$HOOK_EVENT" \
|
||||
--arg tool "$TOOL_NAME" \
|
||||
--arg file "$FILE_PATH" \
|
||||
--arg session "$SESSION_ID" \
|
||||
--arg user "$USER" \
|
||||
'{
|
||||
timestamp: $ts,
|
||||
event: $event,
|
||||
tool: $tool,
|
||||
file: $file,
|
||||
session: $session,
|
||||
user: $user
|
||||
}')
|
||||
|
||||
echo "$AUDIT_ENTRY" >> "$AUDIT_FILE"
|
||||
|
||||
# Rotate: keep only last 10000 entries
|
||||
tail -n 10000 "$AUDIT_FILE" > "$AUDIT_FILE.tmp" && mv "$AUDIT_FILE.tmp" "$AUDIT_FILE"
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### Before Deploying Hooks
|
||||
|
||||
- [ ] All input validated and sanitized
|
||||
- [ ] Path traversal attacks blocked
|
||||
- [ ] Sensitive system paths protected
|
||||
- [ ] All shell variables quoted
|
||||
- [ ] No eval or command injection vectors
|
||||
- [ ] Sensitive data not logged
|
||||
- [ ] Appropriate timeouts set
|
||||
- [ ] Dependencies validated
|
||||
- [ ] Error handling robust
|
||||
- [ ] Audit trail enabled
|
||||
|
||||
### Regular Security Review
|
||||
|
||||
- [ ] Review hook scripts for vulnerabilities
|
||||
- [ ] Check for hardcoded secrets
|
||||
- [ ] Verify timeout values are appropriate
|
||||
- [ ] Audit logged data for sensitive info leaks
|
||||
- [ ] Update blocked patterns for new threats
|
||||
- [ ] Test hooks with malicious input
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env bash
|
||||
# scaffold-hook.sh - Generate hook configuration and script from templates
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Help text
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Usage: $(basename "$0") <hook-name> [options]
|
||||
|
||||
Generate a new Claude Code hook with configuration and script.
|
||||
|
||||
Arguments:
|
||||
hook-name Name of the hook (kebab-case)
|
||||
|
||||
Options:
|
||||
-e, --event Event type (required): PreToolUse, PostToolUse,
|
||||
UserPromptSubmit, Notification, Stop, SubagentStop,
|
||||
PreCompact, SessionStart, SessionEnd
|
||||
-m, --matcher Matcher pattern (default: *)
|
||||
-t, --type Template type: validation, formatting, logging,
|
||||
notification, context (default: validation)
|
||||
-l, --language Script language: bash, typescript, python (default: bash)
|
||||
-o, --output Output directory for script (default: .claude/hooks)
|
||||
-c, --config Config file to update (default: .claude/settings.json)
|
||||
-p, --personal Use personal config (~/.claude/settings.json)
|
||||
--timeout Hook timeout in seconds (default: 30)
|
||||
-i, --interactive Interactive mode (prompts for all values)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
# Simple validation hook
|
||||
$(basename "$0") validate-bash -e PreToolUse -m Bash
|
||||
|
||||
# Python formatter with PostToolUse
|
||||
$(basename "$0") format-python -e PostToolUse -m "Write(*.py)" -t formatting
|
||||
|
||||
# Interactive mode
|
||||
$(basename "$0") my-hook -i
|
||||
|
||||
# Personal hook with custom output
|
||||
$(basename "$0") log-ops -e PreToolUse -m "*" -p
|
||||
|
||||
Event Types:
|
||||
PreToolUse - Before tool execution (can block)
|
||||
PostToolUse - After tool completes successfully
|
||||
UserPromptSubmit - When user submits prompt
|
||||
Notification - When notification sent
|
||||
Stop - When main agent finishes
|
||||
SubagentStop - When subagent finishes
|
||||
PreCompact - Before conversation compacts
|
||||
SessionStart - When session starts/resumes
|
||||
SessionEnd - When session ends
|
||||
|
||||
Matcher Examples:
|
||||
"*" - Match all tools
|
||||
"Write" - Match Write tool only
|
||||
"Write|Edit" - Match Write or Edit
|
||||
"Write(*.py)" - Match Python file writes
|
||||
"mcp__memory__.*" - Match memory MCP tools
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
HOOK_NAME=""
|
||||
EVENT_TYPE=""
|
||||
MATCHER="*"
|
||||
TEMPLATE_TYPE="validation"
|
||||
LANGUAGE="bash"
|
||||
OUTPUT_DIR=".claude/hooks"
|
||||
CONFIG_FILE=".claude/settings.json"
|
||||
TIMEOUT=30
|
||||
INTERACTIVE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-e|--event)
|
||||
EVENT_TYPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-m|--matcher)
|
||||
MATCHER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--type)
|
||||
TEMPLATE_TYPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--language)
|
||||
LANGUAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
OUTPUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c|--config)
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--personal)
|
||||
CONFIG_FILE="$HOME/.claude/settings.json"
|
||||
OUTPUT_DIR="$HOME/.claude/hooks"
|
||||
shift
|
||||
;;
|
||||
--timeout)
|
||||
TIMEOUT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--interactive)
|
||||
INTERACTIVE=true
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
HOOK_NAME="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate hook name
|
||||
if [[ -z "$HOOK_NAME" ]]; then
|
||||
echo -e "${RED}Error: Hook name required${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate hook name format (kebab-case)
|
||||
if [[ ! "$HOOK_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||||
echo -e "${RED}Error: Hook name must be kebab-case (e.g., my-hook)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Interactive mode
|
||||
if [[ "$INTERACTIVE" == "true" ]]; then
|
||||
echo -e "${BLUE}=== Interactive Hook Setup ===${NC}"
|
||||
echo
|
||||
|
||||
# Event type
|
||||
echo -e "${BLUE}Select event type:${NC}"
|
||||
echo "1) PreToolUse - Before tool execution (can block)"
|
||||
echo "2) PostToolUse - After tool completes"
|
||||
echo "3) UserPromptSubmit - When user submits prompt"
|
||||
echo "4) Notification - When notification sent"
|
||||
echo "5) Stop - When agent finishes"
|
||||
echo "6) SubagentStop - When subagent finishes"
|
||||
echo "7) PreCompact - Before compact"
|
||||
echo "8) SessionStart - Session starts"
|
||||
echo "9) SessionEnd - Session ends"
|
||||
read -r -p "Enter number (1-9): " EVENT_NUM
|
||||
|
||||
case $EVENT_NUM in
|
||||
1) EVENT_TYPE="PreToolUse" ;;
|
||||
2) EVENT_TYPE="PostToolUse" ;;
|
||||
3) EVENT_TYPE="UserPromptSubmit" ;;
|
||||
4) EVENT_TYPE="Notification" ;;
|
||||
5) EVENT_TYPE="Stop" ;;
|
||||
6) EVENT_TYPE="SubagentStop" ;;
|
||||
7) EVENT_TYPE="PreCompact" ;;
|
||||
8) EVENT_TYPE="SessionStart" ;;
|
||||
9) EVENT_TYPE="SessionEnd" ;;
|
||||
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Matcher
|
||||
echo
|
||||
echo -e "${BLUE}Enter matcher pattern (e.g., 'Write', '*.py', '*'):${NC}"
|
||||
read -r MATCHER
|
||||
|
||||
# Template type
|
||||
echo
|
||||
echo -e "${BLUE}Select template type:${NC}"
|
||||
echo "1) validation - Validate input and block if needed"
|
||||
echo "2) formatting - Format files after modification"
|
||||
echo "3) logging - Log operations"
|
||||
echo "4) notification - Send notifications"
|
||||
echo "5) context - Add context to prompts"
|
||||
read -r -p "Enter number (1-5): " TEMPLATE_NUM
|
||||
|
||||
case $TEMPLATE_NUM in
|
||||
1) TEMPLATE_TYPE="validation" ;;
|
||||
2) TEMPLATE_TYPE="formatting" ;;
|
||||
3) TEMPLATE_TYPE="logging" ;;
|
||||
4) TEMPLATE_TYPE="notification" ;;
|
||||
5) TEMPLATE_TYPE="context" ;;
|
||||
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Language
|
||||
echo
|
||||
echo -e "${BLUE}Select script language:${NC}"
|
||||
echo "1) bash"
|
||||
echo "2) typescript"
|
||||
echo "3) python"
|
||||
read -r -p "Enter number (1-3): " LANG_NUM
|
||||
|
||||
case $LANG_NUM in
|
||||
1) LANGUAGE="bash" ;;
|
||||
2) LANGUAGE="typescript" ;;
|
||||
3) LANGUAGE="python" ;;
|
||||
*) echo -e "${RED}Invalid selection${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Timeout
|
||||
echo
|
||||
read -r -p "Timeout in seconds (default: 30): " INPUT_TIMEOUT
|
||||
if [[ -n "$INPUT_TIMEOUT" ]]; then
|
||||
if [[ "$INPUT_TIMEOUT" =~ ^[0-9]+$ ]] && [[ "$INPUT_TIMEOUT" -gt 0 ]] && [[ "$INPUT_TIMEOUT" -le 300 ]]; then
|
||||
TIMEOUT="$INPUT_TIMEOUT"
|
||||
else
|
||||
echo -e "${YELLOW}Warning: Invalid timeout value, using default (30s)${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate event type
|
||||
VALID_EVENTS=(PreToolUse PostToolUse UserPromptSubmit Notification Stop SubagentStop PreCompact SessionStart SessionEnd)
|
||||
if [[ -z "$EVENT_TYPE" ]]; then
|
||||
echo -e "${RED}Error: Event type required (-e/--event)${NC}"
|
||||
echo "Valid events: ${VALID_EVENTS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
valid_event=false
|
||||
for evt in "${VALID_EVENTS[@]}"; do
|
||||
if [[ "$evt" == "$EVENT_TYPE" ]]; then
|
||||
valid_event=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$valid_event" == "false" ]]; then
|
||||
echo -e "${RED}Error: Invalid event type: $EVENT_TYPE${NC}"
|
||||
echo "Valid events: ${VALID_EVENTS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate language
|
||||
VALID_LANGUAGES=(bash typescript python)
|
||||
valid_lang=false
|
||||
for lang in "${VALID_LANGUAGES[@]}"; do
|
||||
if [[ "$lang" == "$LANGUAGE" ]]; then
|
||||
valid_lang=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$valid_lang" == "false" ]]; then
|
||||
echo -e "${RED}Error: Invalid language: $LANGUAGE${NC}"
|
||||
echo "Valid languages: ${VALID_LANGUAGES[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine script extension
|
||||
case "$LANGUAGE" in
|
||||
bash) SCRIPT_EXT="sh" ;;
|
||||
typescript) SCRIPT_EXT="ts" ;;
|
||||
python) SCRIPT_EXT="py" ;;
|
||||
esac
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
SCRIPT_PATH="$OUTPUT_DIR/$HOOK_NAME.$SCRIPT_EXT"
|
||||
|
||||
# Check if script already exists
|
||||
if [[ -f "$SCRIPT_PATH" ]]; then
|
||||
echo -e "${YELLOW}Warning: Script already exists: $SCRIPT_PATH${NC}"
|
||||
read -r -p "Overwrite? (y/N): " CONFIRM
|
||||
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate script based on template and language
|
||||
case "$LANGUAGE" in
|
||||
bash)
|
||||
cat > "$SCRIPT_PATH" << 'BASH_EOF'
|
||||
#!/usr/bin/env bash
|
||||
# HOOK_NAME - DESCRIPTION
|
||||
set -euo pipefail
|
||||
|
||||
# Read input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse JSON input
|
||||
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# TEMPLATE_LOGIC
|
||||
|
||||
exit 0
|
||||
BASH_EOF
|
||||
;;
|
||||
|
||||
typescript)
|
||||
cat > "$SCRIPT_PATH" << 'TS_EOF'
|
||||
#!/usr/bin/env bun
|
||||
// HOOK_NAME - DESCRIPTION
|
||||
import { stdin } from "process";
|
||||
|
||||
interface HookInput {
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
hook_event_name: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, any>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Read stdin
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const input: HookInput = JSON.parse(Buffer.concat(chunks).toString());
|
||||
|
||||
// Parse input
|
||||
const hookEvent = input.hook_event_name;
|
||||
const toolName = input.tool_name || "";
|
||||
const filePath = input.tool_input?.file_path || "";
|
||||
|
||||
// TEMPLATE_LOGIC
|
||||
|
||||
process.exit(0);
|
||||
TS_EOF
|
||||
;;
|
||||
|
||||
python)
|
||||
cat > "$SCRIPT_PATH" << 'PY_EOF'
|
||||
#!/usr/bin/env python3
|
||||
"""HOOK_NAME - DESCRIPTION"""
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
|
||||
def main() -> None:
|
||||
"""Main hook logic"""
|
||||
# Read input from stdin
|
||||
try:
|
||||
input_data: Dict[str, Any] = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse input
|
||||
hook_event = input_data.get("hook_event_name", "")
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
file_path = input_data.get("tool_input", {}).get("file_path", "")
|
||||
|
||||
# TEMPLATE_LOGIC
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PY_EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
# Replace placeholders with actual values
|
||||
# Use perl instead of sed for better multiline support
|
||||
perl -i -pe "s/HOOK_NAME/$HOOK_NAME/g" "$SCRIPT_PATH"
|
||||
perl -i -pe "s/DESCRIPTION/Generated hook for $EVENT_TYPE event/g" "$SCRIPT_PATH"
|
||||
|
||||
# Insert template-specific logic by replacing the placeholder line
|
||||
# Generate language-appropriate code based on the language choice
|
||||
insert_template_logic() {
|
||||
local template=$1
|
||||
local lang=$2
|
||||
local replacement=""
|
||||
|
||||
case "$lang" in
|
||||
bash)
|
||||
case "$template" in
|
||||
validation)
|
||||
replacement='# Validation logic\nif [[ -z "$TOOL_NAME" ]]; then\n exit 0\nfi\n\n# Add your validation rules here\n# Example: Block dangerous operations\necho "✓ Validation passed"'
|
||||
;;
|
||||
formatting)
|
||||
replacement='# Formatting logic\nif [[ -z "$FILE_PATH" ]]; then\n exit 0\nfi\n\n# Add your formatting commands here\necho "✓ Formatting completed"'
|
||||
;;
|
||||
logging)
|
||||
replacement='# Logging logic\nLOG_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/hook-logs.log"\nTIMESTAMP=$(date -Iseconds)\necho "[$TIMESTAMP] Event: $HOOK_EVENT, Tool: $TOOL_NAME" >> "$LOG_FILE"'
|
||||
;;
|
||||
notification)
|
||||
replacement='# Notification logic\n# Add your notification code here\necho "✓ Notification sent"'
|
||||
;;
|
||||
context)
|
||||
replacement='# Context injection logic\necho "## Additional Context"\necho "Hook Event: $HOOK_EVENT"\necho "Tool: $TOOL_NAME"\necho "Timestamp: $(date -Iseconds)"'
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
typescript)
|
||||
case "$template" in
|
||||
validation)
|
||||
replacement='// Validation logic\n\tif (!toolName) {\n\t\tprocess.exit(0);\n\t}\n\n\t// Add your validation rules here\n\t// Example: Block dangerous operations\n\tstdout.write("✓ Validation passed\\n");\n\tprocess.exit(0);'
|
||||
;;
|
||||
formatting)
|
||||
replacement='// Formatting logic\n\tif (!filePath) {\n\t\tprocess.exit(0);\n\t}\n\n\t// Add your formatting commands here\n\tstdout.write("✓ Formatting completed\\n");\n\tprocess.exit(0);'
|
||||
;;
|
||||
logging)
|
||||
replacement='// Logging logic\n\tconst logFile = `${process.env.CLAUDE_PROJECT_DIR || "."}/.claude/hook-logs.log`;\n\tconst timestamp = new Date().toISOString();\n\tconst logLine = `[${timestamp}] Event: ${hookEvent}, Tool: ${toolName}\\n`;\n\tfs.appendFileSync(logFile, logLine);\n\tprocess.exit(0);'
|
||||
;;
|
||||
notification)
|
||||
replacement='// Notification logic\n\t// Add your notification code here\n\tstdout.write("✓ Notification sent\\n");\n\tprocess.exit(0);'
|
||||
;;
|
||||
context)
|
||||
replacement='// Context injection logic\n\tstdout.write("## Additional Context\\n");\n\tstdout.write(`Hook Event: ${hookEvent}\\n`);\n\tstdout.write(`Tool: ${toolName}\\n`);\n\tstdout.write(`Timestamp: ${new Date().toISOString()}\\n`);\n\tprocess.exit(0);'
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
python)
|
||||
case "$template" in
|
||||
validation)
|
||||
replacement='# Validation logic\n if not tool_name:\n sys.exit(0)\n\n # Add your validation rules here\n # Example: Block dangerous operations\n print("✓ Validation passed")'
|
||||
;;
|
||||
formatting)
|
||||
replacement='# Formatting logic\n if not file_path:\n sys.exit(0)\n\n # Add your formatting commands here\n print("✓ Formatting completed")'
|
||||
;;
|
||||
logging)
|
||||
replacement='# Logging logic\n import os\n from datetime import datetime\n log_file = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), ".claude", "hook-logs.log")\n timestamp = datetime.now().isoformat()\n with open(log_file, "a") as f:\n f.write(f"[{timestamp}] Event: {hook_event}, Tool: {tool_name}\\n")'
|
||||
;;
|
||||
notification)
|
||||
replacement='# Notification logic\n # Add your notification code here\n print("✓ Notification sent")'
|
||||
;;
|
||||
context)
|
||||
replacement='# Context injection logic\n from datetime import datetime\n print("## Additional Context")\n print(f"Hook Event: {hook_event}")\n print(f"Tool: {tool_name}")\n print(f"Timestamp: {datetime.now().isoformat()}")'
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$replacement" ]]; then
|
||||
perl -i -pe "BEGIN{undef \$/;} s/# TEMPLATE_LOGIC/$replacement/smg" "$SCRIPT_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
insert_template_logic "$TEMPLATE_TYPE" "$LANGUAGE"
|
||||
|
||||
# Make script executable
|
||||
chmod +x "$SCRIPT_PATH"
|
||||
|
||||
echo -e "${GREEN}✓ Created hook script: $SCRIPT_PATH${NC}"
|
||||
|
||||
# Generate hook configuration
|
||||
echo
|
||||
echo -e "${BLUE}Hook configuration to add to $CONFIG_FILE:${NC}"
|
||||
echo
|
||||
|
||||
# Construct relative path if in project
|
||||
if [[ "$OUTPUT_DIR" == ".claude/hooks" ]] || [[ "$OUTPUT_DIR" == "$PWD/.claude/hooks" ]]; then
|
||||
COMMAND_PATH="\$CLAUDE_PROJECT_DIR/.claude/hooks/$HOOK_NAME.$SCRIPT_EXT"
|
||||
elif [[ "$OUTPUT_DIR" == "$HOME/.claude/hooks" ]]; then
|
||||
COMMAND_PATH="$HOME/.claude/hooks/$HOOK_NAME.$SCRIPT_EXT"
|
||||
else
|
||||
COMMAND_PATH="$SCRIPT_PATH"
|
||||
fi
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"hooks": {
|
||||
"$EVENT_TYPE": [
|
||||
{
|
||||
"matcher": "$MATCHER",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$COMMAND_PATH",
|
||||
"timeout": $TIMEOUT
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Next steps:${NC}"
|
||||
echo " 1. Edit $SCRIPT_PATH to add your hook logic"
|
||||
echo " 2. Test the hook: ./scripts/test-hook.ts $SCRIPT_PATH"
|
||||
echo " 3. Add configuration to $CONFIG_FILE"
|
||||
echo " 4. Validate config: ./scripts/validate-hook.sh $CONFIG_FILE"
|
||||
echo
|
||||
|
||||
# Offer to add to config
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
read -r -p "Add this hook to $CONFIG_FILE now? (y/N): " ADD_CONFIG
|
||||
if [[ "$ADD_CONFIG" =~ ^[Yy]$ ]]; then
|
||||
# Check if hooks already exist in config
|
||||
if jq -e '.hooks' "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||
# Hooks exist, merge
|
||||
TEMP_FILE=$(mktemp)
|
||||
jq --arg event "$EVENT_TYPE" \
|
||||
--arg matcher "$MATCHER" \
|
||||
--arg cmd "$COMMAND_PATH" \
|
||||
--argjson timeout "$TIMEOUT" \
|
||||
'.hooks[$event] += [{
|
||||
"matcher": $matcher,
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": $cmd,
|
||||
"timeout": $timeout
|
||||
}]
|
||||
}]' "$CONFIG_FILE" > "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$CONFIG_FILE"
|
||||
else
|
||||
# No hooks, create new structure
|
||||
TEMP_FILE=$(mktemp)
|
||||
jq --arg event "$EVENT_TYPE" \
|
||||
--arg matcher "$MATCHER" \
|
||||
--arg cmd "$COMMAND_PATH" \
|
||||
--argjson timeout "$TIMEOUT" \
|
||||
'. + {
|
||||
"hooks": {
|
||||
($event): [{
|
||||
"matcher": $matcher,
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": $cmd,
|
||||
"timeout": $timeout
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}' "$CONFIG_FILE" > "$TEMP_FILE"
|
||||
mv "$TEMP_FILE" "$CONFIG_FILE"
|
||||
fi
|
||||
echo -e "${GREEN}✓ Added hook to $CONFIG_FILE${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Note: Config file $CONFIG_FILE doesn't exist yet${NC}"
|
||||
echo "You'll need to create it and add the hook configuration manually"
|
||||
fi
|
||||
+482
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* test-hook.ts - Test Claude Code hook scripts with sample input
|
||||
*
|
||||
* Usage:
|
||||
* ./test-hook.ts <hook-script> [options]
|
||||
* ./test-hook.ts validate-bash.sh --event PreToolUse --tool Bash
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { spawn } from "bun";
|
||||
|
||||
// ANSI colors
|
||||
const colors = {
|
||||
red: "\x1b[0;31m",
|
||||
green: "\x1b[0;32m",
|
||||
yellow: "\x1b[1;33m",
|
||||
blue: "\x1b[0;34m",
|
||||
reset: "\x1b[0m",
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON input structure for Claude Code hooks.
|
||||
*/
|
||||
interface HookInput {
|
||||
session_id: string;
|
||||
transcript_path: string;
|
||||
cwd: string;
|
||||
hook_event_name: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for testing a hook script.
|
||||
*/
|
||||
interface TestOptions {
|
||||
event: string;
|
||||
tool?: string;
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
command?: string;
|
||||
reason?: string;
|
||||
customInput?: string;
|
||||
verbose: boolean;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`Usage: test-hook.ts <hook-script> [options]
|
||||
|
||||
Test Claude Code hook scripts with sample input.
|
||||
|
||||
Arguments:
|
||||
hook-script Path to hook script to test
|
||||
|
||||
Options:
|
||||
-e, --event Event type (default: PreToolUse)
|
||||
PreToolUse, PostToolUse, UserPromptSubmit, Notification,
|
||||
Stop, SubagentStop, PreCompact, SessionStart, SessionEnd
|
||||
-t, --tool Tool name (e.g., Write, Edit, Bash)
|
||||
-f, --file File path for tool input
|
||||
-c, --content File content for Write tool
|
||||
--command Command for Bash tool
|
||||
-r, --reason Reason for session events
|
||||
--input Custom JSON input (overrides all other options)
|
||||
--timeout Timeout in milliseconds (default: 5000)
|
||||
-v, --verbose Verbose output
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
# Test PreToolUse hook with Bash tool
|
||||
./test-hook.ts validate-bash.sh -e PreToolUse -t Bash --command "rm -rf /"
|
||||
|
||||
# Test PostToolUse hook with Write tool
|
||||
./test-hook.ts format-code.sh -e PostToolUse -t Write -f test.ts -c "console.log('test');"
|
||||
|
||||
# Test with custom JSON input
|
||||
./test-hook.ts my-hook.sh --input '{"tool_name":"Write","tool_input":{"file_path":"test.txt"}}'
|
||||
|
||||
# Test SessionStart hook
|
||||
./test-hook.ts welcome.sh -e SessionStart -r startup
|
||||
|
||||
Event Types:
|
||||
PreToolUse - Before tool execution (can block)
|
||||
PostToolUse - After tool completes successfully
|
||||
UserPromptSubmit - When user submits prompt
|
||||
Notification - When notification sent
|
||||
Stop - When main agent finishes
|
||||
SubagentStop - When subagent finishes
|
||||
PreCompact - Before conversation compacts
|
||||
SessionStart - When session starts/resumes
|
||||
SessionEnd - When session ends
|
||||
`);
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
function parseArgs(): { scriptPath: string; options: TestOptions } {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const scriptPath = args[0];
|
||||
const options: TestOptions = {
|
||||
event: "PreToolUse",
|
||||
verbose: false,
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "-e":
|
||||
case "--event":
|
||||
options.event = next;
|
||||
i++;
|
||||
break;
|
||||
case "-t":
|
||||
case "--tool":
|
||||
options.tool = next;
|
||||
i++;
|
||||
break;
|
||||
case "-f":
|
||||
case "--file":
|
||||
options.filePath = next;
|
||||
i++;
|
||||
break;
|
||||
case "-c":
|
||||
case "--content":
|
||||
options.content = next;
|
||||
i++;
|
||||
break;
|
||||
case "--command":
|
||||
options.command = next;
|
||||
i++;
|
||||
break;
|
||||
case "-r":
|
||||
case "--reason":
|
||||
options.reason = next;
|
||||
i++;
|
||||
break;
|
||||
case "--input":
|
||||
options.customInput = next;
|
||||
i++;
|
||||
break;
|
||||
case "--timeout": {
|
||||
const parsed = parseInt(next, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
console.error(
|
||||
`${colors.red}Error: --timeout must be a positive number${colors.reset}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
options.timeout = parsed;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
case "-v":
|
||||
case "--verbose":
|
||||
options.verbose = true;
|
||||
break;
|
||||
default:
|
||||
console.error(
|
||||
`${colors.red}Error: Unknown option ${arg}${colors.reset}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { scriptPath, options };
|
||||
}
|
||||
|
||||
// Generate sample input based on event and options
|
||||
function generateInput(options: TestOptions): HookInput {
|
||||
const baseInput: HookInput = {
|
||||
session_id: `test-session-${Date.now()}`,
|
||||
transcript_path: "/tmp/transcript.jsonl",
|
||||
cwd: process.cwd(),
|
||||
hook_event_name: options.event,
|
||||
};
|
||||
|
||||
// Add tool-specific fields
|
||||
if (options.tool) {
|
||||
baseInput.tool_name = options.tool;
|
||||
|
||||
// Generate appropriate tool_input
|
||||
switch (options.tool) {
|
||||
case "Write":
|
||||
baseInput.tool_input = {
|
||||
file_path: options.filePath || "/tmp/test-file.txt",
|
||||
content: options.content || "Test content",
|
||||
};
|
||||
break;
|
||||
|
||||
case "Edit":
|
||||
baseInput.tool_input = {
|
||||
file_path: options.filePath || "/tmp/test-file.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
replace_all: false,
|
||||
};
|
||||
break;
|
||||
|
||||
case "Read":
|
||||
baseInput.tool_input = {
|
||||
file_path: options.filePath || "/tmp/test-file.txt",
|
||||
offset: 0,
|
||||
limit: 2000,
|
||||
};
|
||||
break;
|
||||
|
||||
case "Bash":
|
||||
baseInput.tool_input = {
|
||||
command: options.command || "echo 'test'",
|
||||
description: "Test bash command",
|
||||
};
|
||||
break;
|
||||
|
||||
case "Grep":
|
||||
baseInput.tool_input = {
|
||||
pattern: "test",
|
||||
path: options.filePath || ".",
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
baseInput.tool_input = {
|
||||
file_path: options.filePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add reason for session events
|
||||
if (["SessionStart", "SessionEnd", "PreCompact"].includes(options.event)) {
|
||||
baseInput.reason = options.reason || "test";
|
||||
}
|
||||
|
||||
return baseInput;
|
||||
}
|
||||
|
||||
// Run hook script with input
|
||||
async function runHook(
|
||||
scriptPath: string,
|
||||
input: HookInput,
|
||||
timeout: number,
|
||||
verbose: boolean,
|
||||
): Promise<{
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}> {
|
||||
const inputJson = JSON.stringify(input, null, 2);
|
||||
|
||||
if (verbose) {
|
||||
console.log(`${colors.blue}Input JSON:${colors.reset}`);
|
||||
console.log(inputJson);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Spawn process
|
||||
const proc = spawn({
|
||||
cmd: [scriptPath],
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PROJECT_DIR: process.cwd(),
|
||||
},
|
||||
});
|
||||
|
||||
// Write input to stdin
|
||||
proc.stdin.write(inputJson);
|
||||
proc.stdin.end();
|
||||
|
||||
let timedOut = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill();
|
||||
}, timeout);
|
||||
|
||||
try {
|
||||
const result = await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
return {
|
||||
exitCode: timedOut ? null : result,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const { scriptPath, options } = parseArgs();
|
||||
|
||||
// Validate script path
|
||||
const resolvedPath = resolve(scriptPath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
console.error(
|
||||
`${colors.red}Error: Script not found: ${scriptPath}${colors.reset}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if executable
|
||||
const stats = statSync(resolvedPath);
|
||||
if (!(stats.mode & 0o111)) {
|
||||
console.error(
|
||||
`${colors.yellow}Warning: Script is not executable${colors.reset}`,
|
||||
);
|
||||
console.error(`Run: chmod +x ${scriptPath}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${colors.blue}Testing hook script: ${scriptPath}${colors.reset}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Generate or parse input
|
||||
let input: HookInput;
|
||||
if (options.customInput) {
|
||||
try {
|
||||
input = JSON.parse(options.customInput);
|
||||
console.log(`${colors.blue}Using custom input${colors.reset}`);
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
`${colors.red}Error: Invalid JSON in --input${colors.reset}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
input = generateInput(options);
|
||||
console.log(
|
||||
`${colors.blue}Generated input for ${options.event}${options.tool ? ` with ${options.tool}` : ""}${colors.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.verbose) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Run hook
|
||||
console.log(`${colors.blue}Running hook...${colors.reset}`);
|
||||
console.log();
|
||||
|
||||
const startTime = Date.now();
|
||||
let result: Awaited<ReturnType<typeof runHook>>;
|
||||
|
||||
try {
|
||||
result = await runHook(
|
||||
resolvedPath,
|
||||
input,
|
||||
options.timeout,
|
||||
options.verbose,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Hook execution failed${colors.reset}`);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Display results
|
||||
console.log(`${colors.blue}=== Results ===${colors.reset}`);
|
||||
console.log();
|
||||
|
||||
// Handle timeout
|
||||
if (result.timedOut) {
|
||||
console.log(
|
||||
`${colors.red}✗ Hook timed out after ${options.timeout}ms${colors.reset}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Exit code
|
||||
const exitCode = result.exitCode ?? -1;
|
||||
let exitCodeColor = colors.green;
|
||||
let exitCodeLabel = "Success";
|
||||
|
||||
if (result.timedOut) {
|
||||
exitCodeColor = colors.red;
|
||||
exitCodeLabel = "Timeout (killed)";
|
||||
} else if (exitCode === 2) {
|
||||
exitCodeColor = colors.red;
|
||||
exitCodeLabel = "Blocked (exit 2)";
|
||||
} else if (exitCode !== 0) {
|
||||
exitCodeColor = colors.yellow;
|
||||
exitCodeLabel = "Warning (non-zero)";
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${exitCodeColor}Exit Code: ${exitCode} - ${exitCodeLabel}${colors.reset}`,
|
||||
);
|
||||
console.log(`Duration: ${duration}ms`);
|
||||
console.log();
|
||||
|
||||
// Stdout
|
||||
if (result.stdout) {
|
||||
console.log(`${colors.green}Stdout:${colors.reset}`);
|
||||
console.log(result.stdout);
|
||||
console.log();
|
||||
} else {
|
||||
console.log(`${colors.blue}Stdout: (empty)${colors.reset}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Stderr
|
||||
if (result.stderr) {
|
||||
console.log(`${colors.yellow}Stderr:${colors.reset}`);
|
||||
console.log(result.stderr);
|
||||
console.log();
|
||||
} else {
|
||||
console.log(`${colors.blue}Stderr: (empty)${colors.reset}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log(`${colors.blue}=== Summary ===${colors.reset}`);
|
||||
console.log();
|
||||
|
||||
if (exitCode === 0) {
|
||||
console.log(`${colors.green}✓ Hook executed successfully${colors.reset}`);
|
||||
if (result.stdout) {
|
||||
console.log(" Stdout will be shown to user");
|
||||
}
|
||||
} else if (exitCode === 2) {
|
||||
console.log(
|
||||
`${colors.red}✗ Hook blocked operation (exit 2)${colors.reset}`,
|
||||
);
|
||||
if (result.stderr) {
|
||||
console.log(" Stderr will be shown to Claude");
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`${colors.yellow}⚠ Hook returned warning (exit ${exitCode})${colors.reset}`,
|
||||
);
|
||||
if (result.stderr) {
|
||||
console.log(" Stderr will be shown to user");
|
||||
}
|
||||
}
|
||||
|
||||
// Performance warning
|
||||
if (duration > 1000) {
|
||||
console.log(
|
||||
`${colors.yellow}⚠ Hook took ${duration}ms (>1s)${colors.reset}`,
|
||||
);
|
||||
console.log(" Consider optimizing for faster execution");
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// Exit with same code as hook
|
||||
process.exit(exitCode === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run main
|
||||
main().catch((error) => {
|
||||
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-hook.sh - Validate Claude Code hook configuration and scripts
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Counters
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
# Help text
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Usage: $(basename "$0") <config-file> [options]
|
||||
|
||||
Validate Claude Code hook configuration and referenced scripts.
|
||||
|
||||
Arguments:
|
||||
config-file Path to settings.json file with hooks configuration
|
||||
|
||||
Options:
|
||||
-s, --strict Strict mode (warnings become errors)
|
||||
-q, --quiet Only show errors and warnings
|
||||
--check-scripts Verify all referenced scripts exist and are executable
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
# Validate project hooks
|
||||
$(basename "$0") .claude/settings.json
|
||||
|
||||
# Validate personal hooks
|
||||
$(basename "$0") ~/.claude/settings.json
|
||||
|
||||
# Strict validation with script checking
|
||||
$(basename "$0") .claude/settings.json --strict --check-scripts
|
||||
|
||||
Exit Codes:
|
||||
0 - Validation passed
|
||||
1 - Validation failed with errors
|
||||
EOF
|
||||
}
|
||||
|
||||
# Error reporting
|
||||
error() {
|
||||
((ERRORS++))
|
||||
echo -e "${RED}✗ Error:${NC} $1"
|
||||
}
|
||||
|
||||
warning() {
|
||||
((WARNINGS++))
|
||||
echo -e "${YELLOW}⚠ Warning:${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${BLUE}ℹ Info:${NC} $1"
|
||||
fi
|
||||
}
|
||||
|
||||
success() {
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
CONFIG_FILE=""
|
||||
STRICT=false
|
||||
QUIET=false
|
||||
CHECK_SCRIPTS=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-s|--strict)
|
||||
STRICT=true
|
||||
shift
|
||||
;;
|
||||
-q|--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--check-scripts)
|
||||
CHECK_SCRIPTS=true
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
CONFIG_FILE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate file argument
|
||||
if [[ -z "$CONFIG_FILE" ]]; then
|
||||
echo -e "${RED}Error: Config file required${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
error "File not found: $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &>/dev/null; then
|
||||
error "jq is not installed (required for JSON validation)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start validation
|
||||
if [[ "$QUIET" == "false" ]]; then
|
||||
echo -e "${BLUE}Validating: $CONFIG_FILE${NC}"
|
||||
echo
|
||||
fi
|
||||
|
||||
# 1. Validate JSON syntax
|
||||
if ! jq empty "$CONFIG_FILE" 2>/dev/null; then
|
||||
error "Invalid JSON syntax"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "JSON syntax valid"
|
||||
|
||||
# 2. Check if hooks exist in config
|
||||
if ! jq -e '.hooks' "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||
info "No hooks configuration found (file is valid but has no hooks)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
success "Hooks configuration found"
|
||||
|
||||
# 3. Validate hook structure
|
||||
VALID_EVENTS=(PreToolUse PostToolUse UserPromptSubmit Notification Stop SubagentStop PreCompact SessionStart SessionEnd)
|
||||
|
||||
# Get all event names
|
||||
EVENTS=$(jq -r '.hooks | keys[]' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$EVENTS" ]]; then
|
||||
warning "No events defined in hooks configuration"
|
||||
fi
|
||||
|
||||
TOTAL_HOOKS=0
|
||||
|
||||
while IFS= read -r event; do
|
||||
[[ -z "$event" ]] && continue
|
||||
|
||||
# Check if valid event
|
||||
if [[ ! " ${VALID_EVENTS[*]} " =~ " ${event} " ]]; then
|
||||
error "Invalid event type: $event (valid: ${VALID_EVENTS[*]})"
|
||||
continue
|
||||
fi
|
||||
|
||||
success "Event: $event"
|
||||
|
||||
# Validate event structure
|
||||
EVENT_ARRAY=$(jq -c ".hooks.\"$event\"" "$CONFIG_FILE")
|
||||
|
||||
# Check if array
|
||||
if ! echo "$EVENT_ARRAY" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
error "Event '$event' must be an array"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get array length
|
||||
ARRAY_LENGTH=$(echo "$EVENT_ARRAY" | jq 'length')
|
||||
info " Found $ARRAY_LENGTH matcher(s) for $event"
|
||||
|
||||
# Validate each matcher entry
|
||||
for ((i=0; i<ARRAY_LENGTH; i++)); do
|
||||
MATCHER_ENTRY=$(echo "$EVENT_ARRAY" | jq -c ".[$i]")
|
||||
|
||||
# Check for matcher field
|
||||
if ! echo "$MATCHER_ENTRY" | jq -e '.matcher' >/dev/null 2>&1; then
|
||||
error " Entry $i in $event: missing 'matcher' field"
|
||||
continue
|
||||
fi
|
||||
|
||||
MATCHER=$(echo "$MATCHER_ENTRY" | jq -r '.matcher')
|
||||
info " Matcher: $MATCHER"
|
||||
|
||||
# Validate matcher pattern
|
||||
if [[ -z "$MATCHER" ]]; then
|
||||
warning " Empty matcher pattern"
|
||||
fi
|
||||
|
||||
# Check for hooks array
|
||||
if ! echo "$MATCHER_ENTRY" | jq -e '.hooks' >/dev/null 2>&1; then
|
||||
error " Entry $i in $event: missing 'hooks' array"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! echo "$MATCHER_ENTRY" | jq -e '.hooks | type == "array"' >/dev/null 2>&1; then
|
||||
error " Entry $i in $event: 'hooks' must be an array"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate each hook in the array
|
||||
HOOKS_LENGTH=$(echo "$MATCHER_ENTRY" | jq '.hooks | length')
|
||||
((TOTAL_HOOKS += HOOKS_LENGTH))
|
||||
|
||||
for ((j=0; j<HOOKS_LENGTH; j++)); do
|
||||
HOOK=$(echo "$MATCHER_ENTRY" | jq -c ".hooks[$j]")
|
||||
|
||||
# Check type field
|
||||
if ! echo "$HOOK" | jq -e '.type' >/dev/null 2>&1; then
|
||||
error " Hook $j: missing 'type' field"
|
||||
continue
|
||||
fi
|
||||
|
||||
HOOK_TYPE=$(echo "$HOOK" | jq -r '.type')
|
||||
if [[ "$HOOK_TYPE" != "command" ]]; then
|
||||
error " Hook $j: invalid type '$HOOK_TYPE' (must be 'command')"
|
||||
fi
|
||||
|
||||
# Check command field
|
||||
if ! echo "$HOOK" | jq -e '.command' >/dev/null 2>&1; then
|
||||
error " Hook $j: missing 'command' field"
|
||||
continue
|
||||
fi
|
||||
|
||||
COMMAND=$(echo "$HOOK" | jq -r '.command')
|
||||
info " Command: $COMMAND"
|
||||
|
||||
# Check if command is empty
|
||||
if [[ -z "$COMMAND" ]]; then
|
||||
error " Hook $j: empty command"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check timeout
|
||||
if echo "$HOOK" | jq -e '.timeout' >/dev/null 2>&1; then
|
||||
TIMEOUT=$(echo "$HOOK" | jq -r '.timeout')
|
||||
if ! [[ "$TIMEOUT" =~ ^[0-9]+$ ]]; then
|
||||
error " Hook $j: timeout must be a number"
|
||||
elif [[ "$TIMEOUT" -lt 1 ]]; then
|
||||
error " Hook $j: timeout must be at least 1 second"
|
||||
elif [[ "$TIMEOUT" -gt 300 ]]; then
|
||||
warning " Hook $j: timeout is very long ($TIMEOUT seconds)"
|
||||
fi
|
||||
else
|
||||
info " Using default timeout (30 seconds)"
|
||||
fi
|
||||
|
||||
# Check script if enabled
|
||||
if [[ "$CHECK_SCRIPTS" == "true" ]]; then
|
||||
# Extract script path (handle variables)
|
||||
SCRIPT_PATH="$COMMAND"
|
||||
|
||||
# Replace common variables
|
||||
SCRIPT_PATH="${SCRIPT_PATH//\$CLAUDE_PROJECT_DIR/$(dirname "$CONFIG_FILE")}"
|
||||
SCRIPT_PATH="${SCRIPT_PATH//\$\{CLAUDE_PROJECT_DIR\}/$(dirname "$CONFIG_FILE")}"
|
||||
SCRIPT_PATH="${SCRIPT_PATH//\~/~}"
|
||||
|
||||
# Extract first argument (script path)
|
||||
SCRIPT_PATH=$(echo "$SCRIPT_PATH" | awk '{print $1}')
|
||||
|
||||
# Remove quotes
|
||||
SCRIPT_PATH="${SCRIPT_PATH//\"/}"
|
||||
SCRIPT_PATH="${SCRIPT_PATH//\'/}"
|
||||
|
||||
# Check if script exists
|
||||
if [[ -f "$SCRIPT_PATH" ]]; then
|
||||
success " Script exists: $SCRIPT_PATH"
|
||||
|
||||
# Check if executable
|
||||
if [[ ! -x "$SCRIPT_PATH" ]]; then
|
||||
warning " Script not executable: $SCRIPT_PATH (run: chmod +x $SCRIPT_PATH)"
|
||||
else
|
||||
success " Script is executable"
|
||||
fi
|
||||
|
||||
# Check shebang
|
||||
FIRST_LINE=$(head -n 1 "$SCRIPT_PATH")
|
||||
if [[ ! "$FIRST_LINE" =~ ^#! ]]; then
|
||||
warning " Script missing shebang line"
|
||||
fi
|
||||
elif [[ "$SCRIPT_PATH" =~ ^\$ ]] || [[ "$SCRIPT_PATH" =~ ^(echo|printf|cat|true|false|test|:)$ ]]; then
|
||||
info " Inline/built-in command, skipping script check"
|
||||
else
|
||||
warning " Script not found: $SCRIPT_PATH"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo
|
||||
done <<< "$EVENTS"
|
||||
|
||||
# Summary
|
||||
info "Total hooks validated: $TOTAL_HOOKS"
|
||||
echo
|
||||
|
||||
# 4. Check for common issues
|
||||
|
||||
# Duplicate matchers
|
||||
DUPLICATE_MATCHERS=$(jq -r '.hooks | to_entries[] | .key as $event | .value[] | .matcher | "\($event):\(.)"' "$CONFIG_FILE" | sort | uniq -c | awk '$1 > 1 {print $0}')
|
||||
if [[ -n "$DUPLICATE_MATCHERS" ]]; then
|
||||
warning "Duplicate matchers found (may cause hooks to run multiple times):"
|
||||
echo "$DUPLICATE_MATCHERS" | while read -r line; do
|
||||
warning " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
# Very long commands
|
||||
LONG_COMMANDS=$(jq -r '.hooks | to_entries[] | .value[] | .hooks[] | .command | select(length > 200)' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||||
if [[ -n "$LONG_COMMANDS" ]]; then
|
||||
warning "Very long commands found (consider using script files):"
|
||||
echo "$LONG_COMMANDS" | while IFS= read -r cmd; do
|
||||
warning " ${cmd:0:100}..."
|
||||
done
|
||||
fi
|
||||
|
||||
# Hooks without timeout
|
||||
HOOKS_WITHOUT_TIMEOUT=$(jq '[.hooks | to_entries[] | .value[] | .hooks[] | select(.timeout == null)] | length' "$CONFIG_FILE")
|
||||
if [[ "$HOOKS_WITHOUT_TIMEOUT" -gt 0 ]]; then
|
||||
info "$HOOKS_WITHOUT_TIMEOUT hook(s) using default timeout"
|
||||
fi
|
||||
|
||||
# PreToolUse hooks with long timeouts
|
||||
SLOW_PRE_HOOKS=$(jq -r '.hooks.PreToolUse[]? | .hooks[] | select(.timeout > 10) | .command' "$CONFIG_FILE" 2>/dev/null || echo "")
|
||||
if [[ -n "$SLOW_PRE_HOOKS" ]]; then
|
||||
warning "PreToolUse hooks with timeout >10s (may slow down operations):"
|
||||
echo "$SLOW_PRE_HOOKS" | while IFS= read -r cmd; do
|
||||
warning " $cmd"
|
||||
done
|
||||
fi
|
||||
|
||||
# Convert warnings to errors in strict mode
|
||||
if [[ "$STRICT" == "true" && $WARNINGS -gt 0 ]]; then
|
||||
ERRORS=$((ERRORS + WARNINGS))
|
||||
WARNINGS=0
|
||||
fi
|
||||
|
||||
# Final summary
|
||||
echo
|
||||
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||||
echo -e "${GREEN}✓ Validation passed!${NC}"
|
||||
exit 0
|
||||
elif [[ $ERRORS -eq 0 ]]; then
|
||||
echo -e "${YELLOW}⚠ Validation passed with $WARNINGS warning(s)${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Validation failed with $ERRORS error(s)${NC}"
|
||||
if [[ $WARNINGS -gt 0 ]]; then
|
||||
echo -e "${YELLOW} and $WARNINGS warning(s)${NC}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user