
Hooks Development
- 119 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Use hooks-development for development tasks
About
hooks-development: A skill for development. This provides functionality for development workflows.
- hooks-development
Hooks Development by the numbers
- 119 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,843 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill hooks-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 119 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Use hooks-development for development tasks
Files
Hooks Development
Guide for developing Claude Code hooks with proper output visibility patterns.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use This Skill
- Creating a new PostToolUse or PreToolUse hook
- Hook output is not visible to Claude (most common issue)
- User asks about
decision: blockpattern - Debugging why hook messages don't appear
- User mentions "Claude Code hooks" or "hook visibility"
---
Quick Reference: Visibility Patterns
Critical insight: PostToolUse hook stdout is only visible to Claude when JSON contains "decision": "block".
| Output Format | Claude Visibility |
|---|---|
| Plain text | Not visible |
JSON without decision: block | Not visible |
JSON with decision: block | Visible |
Exit code behavior:
| Exit Code | stdout Behavior | Claude Visibility |
|---|---|---|
| 0 | JSON parsed, shown in verbose mode only | Only if "decision": "block" |
| 2 | Ignored, uses stderr instead | stderr shown to Claude |
| Other | stderr shown in verbose mode | Not shown to Claude |
---
Minimal Working Pattern
/usr/bin/env bash << 'SKILL_SCRIPT_EOF'
#!/usr/bin/env bash
set -euo pipefail
# Read hook payload from stdin
PAYLOAD=$(cat)
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE_PATH" ]] && exit 0
# Your condition here
if [[ condition_met ]]; then
jq -n \
--arg reason "[HOOK] Your message to Claude" \
'{decision: "block", reason: $reason}'
fi
exit 0
SKILL_SCRIPT_EOFKey points:
1. Use jq -n to generate valid JSON 2. Include "decision": "block" for visibility 3. Exit with code 0 4. The "blocking error" label is cosmetic - operation continues
---
TodoWrite Templates
Creating a PostToolUse Hook
1. [pending] Create hook script with shebang and set -euo pipefail
2. [pending] Parse PAYLOAD from stdin with jq
3. [pending] Add condition check for when to trigger
4. [pending] Output JSON with decision:block pattern
5. [pending] Register hook in hooks.json with matcher
6. [pending] Test by editing a matching file
7. [pending] Verify Claude sees the message in system-reminderDebugging Invisible Hook Output
1. [pending] Verify hook executes (add debug log to /tmp)
2. [pending] Check JSON format is valid (pipe to jq .)
3. [pending] Confirm decision:block is present in output
4. [pending] Verify exit code is 0
5. [pending] Check hooks.json matcher pattern
6. [pending] Restart Claude Code session---
Reference Documentation
- Lifecycle Reference - All 10 hook events, diagrams, use cases, configuration pitfalls
- Visibility Patterns - Full exit code and JSON schema details
- Hook Templates - Copy-paste templates for common patterns
- Debugging Guide - Troubleshooting invisible output
---
Post-Change Checklist (Self-Evolution)
When this skill is updated:
- [ ] Update evolution-log.md with discovery
- [ ] Verify code examples still work
- [ ] Check if ADR needs updating: PostToolUse Hook Visibility ADR
---
Related Resources
- ADR: PostToolUse Hook Visibility
- GitHub Issue #3983 - Original bug report
- Claude Code Hooks Reference - Official documentation
---
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Hook output not visible | Missing decision:block in JSON | Add "decision": "block" to JSON output |
| JSON parse error in hook | Invalid JSON syntax | Use jq -n to generate valid JSON |
| Hook not executing | Wrong matcher pattern | Check hooks.json matcher regex matches tool name |
| Plain text output ignored | Only JSON parsed | Wrap output in JSON with decision:block |
| Exit code 2 behavior | stderr used instead of stdout | Use exit 0 with JSON, or exit 2 for stderr messages |
| Session not seeing changes | Hooks cached | Restart Claude Code session after hook changes |
| Verbose mode not showing | Disabled by default | Enable verbose mode in Claude Code settings |
| jq command not found | jq not installed | brew install jq |
Post-Execution Reflection
After this skill completes, check before closing:
1. Did the command succeed? — If not, fix the instruction or error table that caused the failure. 2. Did parameters or output change? — If the underlying tool's interface drifted, update Usage examples and Parameters table to match. 3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.
Only update if the issue is real and reproducible — not speculative.
Debugging Guide
Troubleshooting when hook output is not visible to Claude.
Symptom: Hook Runs But Claude Doesn't See Output
This is the most common issue. Work through this checklist:
Step 1: Verify Hook Executes
Add debug logging to your hook:
/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF'
#!/usr/bin/env bash
set -euo pipefail
# Debug log
echo "$(date): Hook fired" >> /tmp/my-hook-debug.log
echo "PAYLOAD: $(cat)" >> /tmp/my-hook-debug.log
# ... rest of hook
DEBUGGING_GUIDE_SCRIPT_EOFAfter editing a matching file, check:
cat /tmp/my-hook-debug.logIf no log entry: Hook is not being triggered (check matcher pattern).
Step 2: Verify JSON Format
Test your JSON output manually:
echo '{"tool_input":{"file_path":"~/.gitconfig"}}' | ./your-hook.sh | jq .If jq fails: Your hook is outputting invalid JSON.
Step 3: Confirm decision:block Present
Your output MUST include:
{
"decision": "block",
"reason": "Your message"
}Common mistakes:
"decision": "blocked"(wrong value)"decision": true(wrong type)- Missing
decisionfield entirely - Outputting plain text instead of JSON
Step 4: Check Exit Code
Your hook MUST exit with code 0 for JSON output to be processed:
echo '{"tool_input":{"file_path":"~/.gitconfig"}}' | ./your-hook.sh; echo "Exit: $?"- Exit 0: JSON processed, decision:block required for visibility
- Exit 2: JSON ignored, stderr shown instead
- Other: Output ignored
Step 5: Verify Matcher Pattern
In hooks.json or settings.json:
{
"matcher": "Edit|Write",
"hooks": [...]
}The matcher is a regex. Common issues:
"Edit"won't match"Write"- Missing
|for OR patterns - Case sensitivity (use
Edit, notedit)
Step 6: Restart Claude Code
Hooks are loaded at session start. After any changes to:
- Hook script
- hooks.json
- settings.json
You MUST restart Claude Code for changes to take effect.
Common Pitfalls
Pitfall 1: Plain Text Output
# WRONG - Not visible to Claude
echo "File is tracked by chezmoi"# CORRECT - Visible to Claude
jq -n --arg reason "File is tracked" '{decision: "block", reason: $reason}'Pitfall 2: JSON Without decision:block
# WRONG - Not visible to Claude
echo '{"message": "File is tracked"}'# CORRECT - Visible to Claude
echo '{"decision": "block", "reason": "File is tracked"}'Pitfall 3: Using Exit Code 2 with JSON
# WRONG - JSON ignored with exit 2
jq -n --arg reason "Message" '{decision: "block", reason: $reason}'
exit 2 # JSON ignored, stderr used instead# CORRECT for soft reminder - JSON processed
jq -n --arg reason "Message" '{decision: "block", reason: $reason}'
exit 0# CORRECT for hard block - stderr used
echo "BLOCKED: Dangerous operation" >&2
exit 2Pitfall 4: Silent Failures
/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF_2'
# WRONG - jq error silently swallowed
PAYLOAD=$(cat)
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.wrong.path') # Returns empty, no error
DEBUGGING_GUIDE_SCRIPT_EOF_2/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF_3'
# BETTER - Explicit error handling
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE_PATH" ]] && exit 0
DEBUGGING_GUIDE_SCRIPT_EOF_3Pitfall 5: Not Handling Missing Fields
/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF_4'
# WRONG - Fails if file_path missing
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path')
DEBUGGING_GUIDE_SCRIPT_EOF_4/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF_5'
# CORRECT - Graceful fallback
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE_PATH" ]] && exit 0
DEBUGGING_GUIDE_SCRIPT_EOF_5Quick Diagnostic Script
Save as test-hook.sh:
/usr/bin/env bash << 'DEBUGGING_GUIDE_SCRIPT_EOF_6'
#!/usr/bin/env bash
# Test a hook manually
HOOK_PATH="$1"
TEST_FILE="$2"
if [[ -z "$HOOK_PATH" ]] || [[ -z "$TEST_FILE" ]]; then
echo "Usage: test-hook.sh <hook-path> <test-file-path>"
exit 1
fi
# Simulate PostToolUse:Edit payload
PAYLOAD=$(jq -n --arg path "$TEST_FILE" '{
tool_name: "Edit",
tool_input: {file_path: $path}
}')
echo "=== Testing: $HOOK_PATH ==="
echo "=== Payload: $PAYLOAD ==="
echo "=== Output: ==="
OUTPUT=$(echo "$PAYLOAD" | "$HOOK_PATH")
EXIT_CODE=$?
echo "$OUTPUT"
echo "=== Exit Code: $EXIT_CODE ==="
if [[ $EXIT_CODE -eq 0 ]] && echo "$OUTPUT" | jq -e '.decision == "block"' >/dev/null 2>&1; then
echo "=== PASS: decision:block found, exit 0 ==="
else
echo "=== FAIL: Missing decision:block or wrong exit code ==="
fi
DEBUGGING_GUIDE_SCRIPT_EOF_6Usage:
chmod +x test-hook.sh
./test-hook.sh ./my-hook.sh ~/.gitconfigReference
- Visibility Patterns - Full exit code and JSON schema
- ADR: PostToolUse Hook Visibility
Evolution Log
Changelog for hooks-development skill discoveries and updates.
v1.0.0 (2025-12-17)
Initial Release
- Created hooks-development skill documenting PostToolUse visibility patterns
- Documented
decision: blockrequirement for Claude visibility - Added exit code behavior table
- Included working templates from chezmoi-sync-reminder.sh
- Created debugging guide for invisible hook output
Discovery Source: Debugging session with chezmoi-sync-reminder hook where stdout was not visible to Claude despite hook executing successfully.
Key Insight: PostToolUse hook stdout requires JSON with "decision": "block" field for Claude to receive the message. This is counterintuitive since the operation is not actually blocked.
References:
- GitHub Issue #3983
- ADR: PostToolUse Hook Visibility
---
Template for Future Entries
## vX.Y.Z (YYYY-MM-DD)
**Change Type**: [Discovery | Enhancement | Fix | Deprecation]
**Summary**: Brief description of what changed
**Discovery Source**: How this was learned (debugging session, user report, documentation review)
**Key Insight**: The important takeaway for future reference
**Files Modified**:
- `SKILL.md`: What changed
- `references/X.md`: What changed
**References**:
- Links to related issues, ADRs, or documentationHook Templates
Copy-paste templates for common hook patterns.
Plugin hooks.json Structure
Every plugin with hooks must have hooks/hooks.json using this canonical object format. The .hooks key must be an object keyed by event type — never a flat array.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/<plugin>/hooks/<script>",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/<plugin>/hooks/<script>",
"timeout": 5000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/<plugin>/hooks/<script>",
"timeout": 10000
}
]
}
]
}
}Rules:
matcher— Regex against tool name. Required for PreToolUse/PostToolUse. Optional for Stop.timeout— Milliseconds. Default is 600000 (10 min). Set explicit lower values for fast-fail hooks.- Always use `$HOME`-based paths, never
${CLAUDE_PLUGIN_ROOT}(it's not a shell env var — see Common Pitfalls in lifecycle-reference.md). - Include only the event types your plugin uses. Most plugins only need 1-2.
PostToolUse: Non-Blocking Reminder
Use when you want Claude to see a message but NOT block the operation.
/usr/bin/env bash << 'PREFLIGHT_EOF'
#!/usr/bin/env bash
# PostToolUse hook - non-blocking reminder
# Trigger: PostToolUse on Edit|Write (configure in hooks.json)
set -euo pipefail
# Read JSON payload from stdin
PAYLOAD=$(cat)
# Extract file path from tool input
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
# Exit silently if no file path
[[ -z "$FILE_PATH" ]] && exit 0
# Your condition check here
if [[ "$FILE_PATH" == *"some_pattern"* ]]; then
# Output JSON with decision:block - REQUIRED for Claude visibility
jq -n \
--arg reason "[HOOK_NAME] Your message to Claude here" \
'{decision: "block", reason: $reason}'
fi
exit 0
PREFLIGHT_EOFhooks.json Entry
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/plugins/.../hooks/your-hook.sh",
"timeout": 5000
}
]
}
]
}PreToolUse: Blocking Guard
Use when you want to STOP an operation from proceeding.
/usr/bin/env bash << 'PREFLIGHT_EOF_2'
#!/usr/bin/env bash
# PreToolUse hook - blocking guard
# Trigger: PreToolUse on Bash (configure in hooks.json)
set -euo pipefail
PAYLOAD=$(cat)
# Extract command being executed
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty')
[[ -z "$COMMAND" ]] && exit 0
# Check for dangerous pattern
if [[ "$COMMAND" == *"rm -rf"* ]]; then
# Exit code 2 = hard block, stderr shown to Claude
echo "BLOCKED: Dangerous rm -rf command detected" >&2
exit 2
fi
exit 0
PREFLIGHT_EOF_2hooks.json Entry
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/plugins/.../hooks/guard.sh",
"timeout": 15
}
]
}
]
}PostToolUse: With Cache for Performance
Use when you need to check against a list that's expensive to generate.
/usr/bin/env bash << 'PREFLIGHT_EOF_3'
#!/usr/bin/env bash
# PostToolUse hook with caching
set -euo pipefail
PAYLOAD=$(cat)
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE_PATH" ]] && exit 0
# Expand ~ to absolute path
ABSOLUTE_PATH=$(eval echo "$FILE_PATH")
# Cache with 5-minute TTL
CACHE_FILE="${TMPDIR:-/tmp}/my-hook-cache.txt"
if [[ ! -f "$CACHE_FILE" ]] || [[ $(find "$CACHE_FILE" -mmin +5 2>/dev/null) ]]; then
# Regenerate cache (expensive operation)
generate_list_command > "$CACHE_FILE" || exit 0
fi
# Check against cached list
if grep -qxF "$ABSOLUTE_PATH" "$CACHE_FILE" 2>/dev/null; then
jq -n \
--arg reason "[HOOK] File is in tracked list: $ABSOLUTE_PATH" \
'{decision: "block", reason: $reason}'
fi
exit 0
PREFLIGHT_EOF_3Bash Boilerplate
Common patterns used across hooks:
/usr/bin/env bash << 'HOOK_TEMPLATES_SCRIPT_EOF'
#!/usr/bin/env bash
set -euo pipefail
# Read payload
PAYLOAD=$(cat)
# Common extractions
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty')
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty')
# Path expansion
ABSOLUTE_PATH=$(eval echo "$FILE_PATH")
REL_PATH="${ABSOLUTE_PATH/#$HOME/~}"
# Safe JSON output with jq
jq -n \
--arg reason "Your message" \
--arg context "Extra info" \
'{
decision: "block",
reason: $reason,
hookSpecificOutput: {
additionalContext: $context
}
}'
HOOK_TEMPLATES_SCRIPT_EOFTesting Your Hook
1. Make hook executable:
chmod +x your-hook.sh2. Add to settings.json (or hooks.json for plugins):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "/path/to/your-hook.sh" }]
}
]
}
}3. Restart Claude Code session
4. Edit a file that matches your condition
5. Check for system-reminder in conversation
Hook Visibility Patterns
Detailed documentation on how Claude Code processes hook output.
The Core Problem
PostToolUse hooks execute successfully but their stdout is not visible to Claude. This is by design - Claude Code only surfaces hook output when specific conditions are met.
Exit Code Behavior
| Exit Code | stdout Processing | stderr Processing | Claude Visibility |
|---|---|---|---|
| 0 | JSON parsed, shown in verbose mode only | Ignored | Only if "decision": "block" |
| 2 | Ignored entirely | Shown to Claude | stderr visible |
| Other | Ignored | Shown in verbose mode | Not visible to Claude |
Exit Code 0: The Default Path
When hook exits with code 0:
1. stdout is expected to be JSON 2. JSON is parsed but NOT shown to Claude by default 3. Only the reason field is shown IF decision equals "block" 4. The operation continues normally (despite the "blocking" terminology)
Exit Code 2: Hard Block Path
When hook exits with code 2:
1. stdout is completely ignored 2. stderr is shown to Claude and user 3. Operation is blocked (user must confirm to proceed) 4. Use for genuine blocking scenarios (security issues, invalid state)
JSON Output Schema
Full schema for exit code 0 hooks:
{
"decision": "block",
"reason": "Message visible to Claude",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Extra context (informational only, not shown to Claude)"
},
"continue": true,
"suppressOutput": true,
"systemMessage": "Optional warning shown to user"
}Required Fields for Visibility
| Field | Required | Purpose |
|---|---|---|
decision | Yes | Must be "block" for output |
reason | Yes | The message Claude sees |
Optional Fields
| Field | Default | Purpose |
|---|---|---|
continue | true | Whether to continue after hook |
suppressOutput | false | Hide tool output from user |
systemMessage | null | Warning message for user (not Claude) |
hookSpecificOutput | null | Additional context (logged only) |
Why "decision: block" When Not Blocking?
This is a known UX issue documented in GitHub Issue #3983.
The terminology is misleading:
"decision": "block"does NOT block the operation- It just means "show this to Claude"
- The operation continues normally with exit code 0
Think of it as: "block" = "break into Claude's attention" rather than "block the operation"
Working Example
From chezmoi-sync-reminder.sh:
/usr/bin/env bash << 'PREFLIGHT_EOF'
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE_PATH" ]] && exit 0
# Expand ~ to absolute path
ABSOLUTE_PATH=$(eval echo "$FILE_PATH")
# Check if file is chezmoi-managed
if grep -qxF "$ABSOLUTE_PATH" "$CACHE_FILE" 2>/dev/null; then
REL_PATH="${ABSOLUTE_PATH/#$HOME/~}"
# Output JSON with decision:block - REQUIRED for Claude to see
jq -n \
--arg reason "[CHEZMOI] $REL_PATH is tracked. Sync with: chezmoi add $REL_PATH" \
'{decision: "block", reason: $reason}'
fi
exit 0
PREFLIGHT_EOFWhat Claude Sees
When this hook fires, Claude receives a system-reminder like:
PostToolUse:Edit hook blocking error from command: "...chezmoi-sync-reminder.sh":
[CHEZMOI] ~/.gitconfig is tracked. Sync with: chezmoi add ~/.gitconfigThe "blocking error" label is cosmetic - the edit operation completed successfully.
References
- ADR: PostToolUse Hook Visibility
- GitHub Issue #3983
- Claude Code Hooks Reference