chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"patterns":{"logging":{"desc":"Log tool usage","struct":{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"echo \"[$(date)] $CLAUDE_TOOL_NAME\" >> ~/.claude/activity.log"}]}]}}},"backup":{"desc":"Backup before edit","struct":{"hooks":{"PreToolUse":[{"matcher":"Edit|MultiEdit","hooks":[{"type":"command","command":"[[ -f \"$CLAUDE_TOOL_FILE_PATH\" ]] && cp \"$CLAUDE_TOOL_FILE_PATH\" \"$CLAUDE_TOOL_FILE_PATH.$(date +%s).bak\" 2>/dev/null || true"}]}]}}},"format":{"desc":"Auto-format files","struct":{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"if [[ \"$CLAUDE_TOOL_FILE_PATH\" =~ \\.(js|ts)$ ]]; then npx prettier --write \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then black \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"}]}]}}},"git":{"desc":"Git operations","struct":{"hooks":{"PostToolUse":[{"matcher":"Edit|Write","hooks":[{"type":"command","command":"git rev-parse --git-dir >/dev/null 2>&1 && git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true"}]}]}}},"notify":{"desc":"Desktop notifications","struct":{"hooks":{"PostToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"if command -v osascript >/dev/null; then osascript -e 'display notification \"$CLAUDE_TOOL_NAME completed\" with title \"Claude Code\"'; elif command -v notify-send >/dev/null; then notify-send \"Claude Code\" \"$CLAUDE_TOOL_NAME completed\"; fi"}]}]}}},"test":{"desc":"Run tests","struct":{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"if [[ -f package.json ]]; then npm test 2>/dev/null || yarn test 2>/dev/null || true; elif [[ -f pytest.ini ]]; then pytest 2>/dev/null || true; fi"}]}]}}},"build":{"desc":"Auto build","struct":{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"if [[ -f package.json ]] && grep -q '\"build\"' package.json; then npm run build 2>/dev/null || true; elif [[ -f Makefile ]]; then make 2>/dev/null || true; fi"}]}]}}},"security":{"desc":"Security scan","struct":{"hooks":{"PostToolUse":[{"matcher":"Edit|Write","hooks":[{"type":"command","command":"if command -v gitleaks >/dev/null; then gitleaks detect --source=\"$CLAUDE_TOOL_FILE_PATH\" --no-git 2>/dev/null || true; fi; grep -qE '(password|secret|key)\\s*=' \"$CLAUDE_TOOL_FILE_PATH\" && echo \"⚠️ Potential secrets in $CLAUDE_TOOL_FILE_PATH\" || true"}]}]}}},"protect":{"desc":"File protection","struct":{"hooks":{"PreToolUse":[{"matcher":"Edit|Write","hooks":[{"type":"command","command":"for p in '/etc/*' '/usr/bin/*' '*.production.*' '*prod*config*' '/node_modules/*'; do [[ \"$CLAUDE_TOOL_FILE_PATH\" == $p ]] && echo \"Error: Protected file\" >&2 && exit 1; done"}]}]}}}},"commands":{"check_file":"[[ -f \"$CLAUDE_TOOL_FILE_PATH\" ]]","check_git":"git rev-parse --git-dir >/dev/null 2>&1","suppress_errors":"2>/dev/null || true","timestamp":"$(date +%Y%m%d_%H%M%S)","check_package":"[[ -f package.json ]]","file_ext_js":"[[ \"$CLAUDE_TOOL_FILE_PATH\" =~ \\.(js|ts|jsx|tsx)$ ]]","file_ext_py":"[[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]","macos_notify":"osascript -e 'display notification \"$CLAUDE_TOOL_NAME\" with title \"Claude Code\"'","linux_notify":"notify-send \"Claude Code\" \"$CLAUDE_TOOL_NAME\""},"structure":"ALWAYS_USE_ARRAYS_FOR_EVENTS","required_fields":["description","hooks"],"env_vars":{"CLAUDE_TOOL_NAME":"current_tool","CLAUDE_TOOL_FILE_PATH":"file_path","CLAUDE_PROJECT_DIR":"project_root"}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Automatically loads AGENTS.md configuration file content at session start to ensure Claude Code follows project-specific agent behavior. Only loads if AGENTS.md exists, otherwise passes empty context. Supports the universal AGENTS.md standard for cross-platform AI assistant compatibility.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -c \"import json, sys, os; agents_content = open('AGENTS.md', 'r').read() if os.path.exists('AGENTS.md') else ''; output = {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': agents_content}}; print(json.dumps(output))\"",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically trigger build processes when source files change. Detects common build tools and runs appropriate build commands.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -f package.json ]] && grep -q '\"build\"' package.json; then npm run build 2>/dev/null || yarn build 2>/dev/null || true; elif [[ -f Makefile ]]; then make 2>/dev/null || true; elif [[ -f Cargo.toml ]]; then cargo build 2>/dev/null || true; elif [[ -f pom.xml ]]; then mvn compile 2>/dev/null || true; elif [[ -f build.gradle ]]; then ./gradlew build 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"description": "Log every file mutation to CSV for demo prep. Records timestamp, tool, file path, action, and details for Edit, MultiEdit, Write, and Bash operations. Output: .claude/critical_log_changes.csv",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "change-logger.py",
|
||||
"destination": ".claude/hooks/change-logger.py",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/change-logger.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/change-logger.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/change-logger.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/change-logger.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Change Logger Hook
|
||||
Logs every file mutation (Edit, Write, Bash) to a CSV file for demo prep and session review.
|
||||
Output: .claude/critical_log_changes.csv
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Read-only commands that should not be logged
|
||||
READONLY_COMMANDS = {
|
||||
"cat", "head", "tail", "less", "more",
|
||||
"ls", "dir", "tree", "pwd", "which", "where", "whereis",
|
||||
"echo", "printf",
|
||||
"grep", "rg", "find", "fd", "ag",
|
||||
"git status", "git log", "git diff", "git show", "git branch",
|
||||
"git remote", "git stash list", "git tag",
|
||||
"node -e", "python -c", "ruby -e",
|
||||
"type", "file", "wc", "du", "df",
|
||||
}
|
||||
|
||||
CSV_PATH = ".claude/critical_log_changes.csv"
|
||||
CSV_HEADERS = ["timestamp", "tool", "file_path", "action", "details"]
|
||||
|
||||
|
||||
def is_readonly_command(command):
|
||||
"""Check if a bash command is read-only and should be skipped."""
|
||||
cmd_stripped = command.strip()
|
||||
for ro_cmd in READONLY_COMMANDS:
|
||||
if cmd_stripped.startswith(ro_cmd):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def log_change(tool_name, file_path, action, details=""):
|
||||
"""Append a row to the CSV change log."""
|
||||
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
|
||||
|
||||
write_header = not os.path.exists(CSV_PATH)
|
||||
|
||||
with open(CSV_PATH, "a", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f, quoting=csv.QUOTE_MINIMAL)
|
||||
if write_header:
|
||||
writer.writerow(CSV_HEADERS)
|
||||
writer.writerow([
|
||||
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
tool_name,
|
||||
file_path,
|
||||
action,
|
||||
details[:200],
|
||||
])
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError):
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = data.get("tool_name", "")
|
||||
tool_input = data.get("tool_input", {})
|
||||
|
||||
if tool_name in ("Edit", "MultiEdit"):
|
||||
file_path = tool_input.get("file_path", "unknown")
|
||||
log_change(tool_name, file_path, "modified")
|
||||
|
||||
elif tool_name == "Write":
|
||||
file_path = tool_input.get("file_path", "unknown")
|
||||
log_change(tool_name, file_path, "created")
|
||||
|
||||
elif tool_name == "Bash":
|
||||
command = tool_input.get("command", "")
|
||||
if command and not is_readonly_command(command):
|
||||
log_change(tool_name, "-", "executed", command[:200])
|
||||
|
||||
# Never block tool execution
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Advanced dependency analysis and security checking. Monitors for outdated packages, security vulnerabilities, and license compatibility.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *package.json || \"$CLAUDE_TOOL_FILE_PATH\" == *requirements.txt || \"$CLAUDE_TOOL_FILE_PATH\" == *Cargo.toml || \"$CLAUDE_TOOL_FILE_PATH\" == *pom.xml || \"$CLAUDE_TOOL_FILE_PATH\" == *Gemfile ]]; then echo \"Dependency file modified: $CLAUDE_TOOL_FILE_PATH\"; if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *package.json ]] && command -v npm >/dev/null 2>&1; then npm audit 2>/dev/null || true; npx npm-check-updates 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *requirements.txt ]] && command -v safety >/dev/null 2>&1; then safety check -r \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *Cargo.toml ]] && command -v cargo >/dev/null 2>&1; then cargo audit 2>/dev/null || true; fi; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "Monitor deployment status, error rates, and performance metrics, sending notifications for failed deployments or performance degradation. Tracks Vercel deployment health, monitors build success/failure rates, and provides alerts for deployment issues. Setup: Export 'export VERCEL_TOKEN=your_token' and 'export VERCEL_PROJECT_ID=your_project_id' (get from vercel.com/account/tokens and Vercel dashboard).",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); COMMAND=$(echo \"$input\" | jq -r \".tool_input.command // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [[ \"$COMMAND\" =~ (vercel|deploy|build) ]] && [ -n \"$VERCEL_TOKEN\" ] && [ -n \"$VERCEL_PROJECT_ID\" ]; then echo \"🏥 Deployment Health Monitor: Checking deployment status...\"; DEPLOY_DATA=$(curl -s -H \"Authorization: Bearer $VERCEL_TOKEN\" \"https://api.vercel.com/v6/deployments?projectId=$VERCEL_PROJECT_ID&limit=5\" 2>/dev/null); if [ -n \"$DEPLOY_DATA\" ] && [ \"$DEPLOY_DATA\" != \"null\" ]; then RECENT_DEPLOYMENTS=$(echo \"$DEPLOY_DATA\" | jq -r \".deployments[]\"); TOTAL_DEPLOYMENTS=$(echo \"$DEPLOY_DATA\" | jq \".deployments | length\"); SUCCESS_COUNT=0; ERROR_COUNT=0; BUILDING_COUNT=0; echo \"📊 Recent deployment analysis ($TOTAL_DEPLOYMENTS deployments):\"; echo \"$DEPLOY_DATA\" | jq -r \".deployments[] | \\\"State: \\(.state) | Created: \\(.created | todateiso8601) | URL: \\(.url // \\\"N/A\\\")\\\"\"; for state in $(echo \"$DEPLOY_DATA\" | jq -r \".deployments[].state\"); do case \"$state\" in READY) ((SUCCESS_COUNT++));; ERROR|CANCELED) ((ERROR_COUNT++));; BUILDING|QUEUED) ((BUILDING_COUNT++));; esac; done; SUCCESS_RATE=$(( SUCCESS_COUNT * 100 / TOTAL_DEPLOYMENTS )); echo \"\" ; echo \"📈 Deployment Health Summary:\"; echo \"✅ Successful: $SUCCESS_COUNT/$TOTAL_DEPLOYMENTS ($SUCCESS_RATE%)\"; echo \"❌ Failed: $ERROR_COUNT/$TOTAL_DEPLOYMENTS\"; echo \"🔄 In Progress: $BUILDING_COUNT/$TOTAL_DEPLOYMENTS\"; if [ $ERROR_COUNT -gt 0 ]; then echo \"\" >&2; echo \"🚨 DEPLOYMENT HEALTH ALERT!\" >&2; echo \"Recent failures detected: $ERROR_COUNT failed deployments\" >&2; echo \"Success rate: $SUCCESS_RATE%\" >&2; echo \"\" >&2; echo \"🔍 Failed deployments:\" >&2; echo \"$DEPLOY_DATA\" | jq -r \".deployments[] | select(.state == \\\"ERROR\\\" or .state == \\\"CANCELED\\\") | \\\"❌ \\(.created | todateiso8601): \\(.url // \\\"No URL\\\")\\\"\" >&2; echo \"\" >&2; echo \"💡 Troubleshooting steps:\" >&2; echo \"• Check Vercel dashboard for detailed error logs\" >&2; echo \"• Review recent code changes\" >&2; echo \"• Verify environment variables are set\" >&2; echo \"• Check for build script errors\" >&2; if [ $SUCCESS_RATE -lt 50 ]; then echo \"🚨 CRITICAL: Success rate below 50%!\" >&2; exit 2; fi; elif [ $SUCCESS_RATE -lt 80 ]; then echo \"⚠️ Warning: Success rate below 80%\"; fi; if [ $BUILDING_COUNT -gt 2 ]; then echo \"⏳ Multiple builds in progress ($BUILDING_COUNT)\"; echo \"💡 This might indicate build queue issues\"; fi; LATEST_DEPLOY=$(echo \"$DEPLOY_DATA\" | jq -r \".deployments[0]\"); LATEST_STATE=$(echo \"$LATEST_DEPLOY\" | jq -r \".state\"); LATEST_URL=$(echo \"$LATEST_DEPLOY\" | jq -r \".url // empty\"); LATEST_CREATED=$(echo \"$LATEST_DEPLOY\" | jq -r \".created\"); if [ -n \"$LATEST_CREATED\" ] && [ \"$LATEST_CREATED\" != \"null\" ]; then MINUTES_AGO=$(( ($(date +%s) - $LATEST_CREATED/1000) / 60 )); echo \"🕒 Latest deployment: $LATEST_STATE ($MINUTES_AGO minutes ago)\"; if [ -n \"$LATEST_URL\" ]; then echo \"🌐 URL: https://$LATEST_URL\"; fi; fi; echo \"✅ Deployment health check completed\"; else echo \"❌ Unable to fetch deployment data from Vercel API\"; fi; else echo \"ℹ️ Deployment health monitoring skipped (not a deployment command or missing tokens)\"; fi'",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'if [ -n \"$VERCEL_TOKEN\" ] && [ -n \"$VERCEL_PROJECT_ID\" ]; then echo \"🏥 Deployment Health Monitor: Initial health check...\"; DEPLOY_DATA=$(curl -s -H \"Authorization: Bearer $VERCEL_TOKEN\" \"https://api.vercel.com/v6/deployments?projectId=$VERCEL_PROJECT_ID&limit=1\" 2>/dev/null); if [ -n \"$DEPLOY_DATA\" ] && [ \"$DEPLOY_DATA\" != \"null\" ]; then LATEST_STATE=$(echo \"$DEPLOY_DATA\" | jq -r \".deployments[0].state // empty\"); LATEST_URL=$(echo \"$DEPLOY_DATA\" | jq -r \".deployments[0].url // empty\"); LATEST_CREATED=$(echo \"$DEPLOY_DATA\" | jq -r \".deployments[0].created // empty\"); if [ -n \"$LATEST_CREATED\" ] && [ \"$LATEST_CREATED\" != \"null\" ]; then MINUTES_AGO=$(( ($(date +%s) - $LATEST_CREATED/1000) / 60 )); case \"$LATEST_STATE\" in READY) echo \"✅ Latest deployment: READY ($MINUTES_AGO minutes ago)\"; if [ -n \"$LATEST_URL\" ]; then echo \"🌐 Live at: https://$LATEST_URL\"; fi;; ERROR) echo \"❌ Latest deployment: FAILED ($MINUTES_AGO minutes ago)\" >&2; echo \"🔧 Check Vercel dashboard for details\" >&2;; BUILDING) echo \"🔄 Latest deployment: BUILDING ($MINUTES_AGO minutes ago)\"; echo \"⏳ Build in progress...\";; QUEUED) echo \"⏳ Latest deployment: QUEUED ($MINUTES_AGO minutes ago)\";; *) echo \"❓ Latest deployment: $LATEST_STATE ($MINUTES_AGO minutes ago)\";; esac; echo \"📊 Deployment monitoring active\"; else echo \"ℹ️ No recent deployments found\"; fi; else echo \"⚠️ Unable to connect to Vercel API\"; fi; else echo \"ℹ️ Deployment health monitoring disabled (VERCEL_TOKEN or VERCEL_PROJECT_ID not set)\"; fi'",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "Send detailed Discord notifications with session information when Claude Code finishes. Includes working directory, session duration, and system info with rich embeds. Requires DISCORD_WEBHOOK_URL environment variable.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then echo \"$(date +%s)\" > ~/.claude/session_start.tmp; PROJECT_DIR=\"$(basename \"$(pwd)\")\"; MESSAGE='{\"embeds\":[{\"title\":\"🚀 Claude Code Session Started\",\"color\":3447003,\"fields\":[{\"name\":\"📁 Project\",\"value\":\"'\"$PROJECT_DIR\"'\",\"inline\":true},{\"name\":\"⏰ Time\",\"value\":\"'\"$(date '+%H:%M:%S')\"'\",\"inline\":true},{\"name\":\"📅 Date\",\"value\":\"'\"$(date '+%Y-%m-%d')\"'\",\"inline\":true}],\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"'\"}]}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then END_TIME=\"$(date +%s)\"; if [[ -f ~/.claude/session_start.tmp ]]; then START_TIME=\"$(cat ~/.claude/session_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; DURATION_TEXT=\"${MINUTES}m ${SECONDS}s\"; rm -f ~/.claude/session_start.tmp; else DURATION_TEXT=\"Unknown\"; fi; PROJECT_DIR=\"$(basename \"$(pwd)\")\"; MEMORY_MB=\"$(ps -o rss= -p $$ 2>/dev/null | awk '{print int($1/1024)}' || echo 'N/A')\"; MESSAGE='{\"embeds\":[{\"title\":\"✅ Claude Code Session Completed\",\"color\":5763719,\"fields\":[{\"name\":\"📁 Project\",\"value\":\"'\"$PROJECT_DIR\"'\",\"inline\":true},{\"name\":\"⏱️ Duration\",\"value\":\"'\"$DURATION_TEXT\"'\",\"inline\":true},{\"name\":\"💾 Memory Used\",\"value\":\"'\"${MEMORY_MB}\"'MB\",\"inline\":true},{\"name\":\"⏰ Finished\",\"value\":\"'\"$(date '+%H:%M:%S')\"'\",\"inline\":true},{\"name\":\"📅 Date\",\"value\":\"'\"$(date '+%Y-%m-%d')\"'\",\"inline\":true}],\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"'\"}]}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send detailed Discord notification\"; else echo \"⚠️ Detailed Discord notification skipped: Set DISCORD_WEBHOOK_URL\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Send Discord notifications when Claude Code encounters long-running operations or when tools take significant time. Helps monitor productivity and catch potential issues with rich embeds. Requires DISCORD_WEBHOOK_URL environment variable.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then echo \"$(date +%s)\" > ~/.claude/bash_start.tmp; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" && -f ~/.claude/bash_start.tmp ]]; then END_TIME=\"$(date +%s)\"; START_TIME=\"$(cat ~/.claude/bash_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; rm -f ~/.claude/bash_start.tmp; if [[ $DURATION -gt 30 ]]; then MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; MESSAGE='{\"embeds\":[{\"title\":\"⚠️ Long Bash Operation\",\"color\":16776960,\"fields\":[{\"name\":\"⏱️ Duration\",\"value\":\"'\"${MINUTES}\"'m '\"${SECONDS}\"'s\",\"inline\":true},{\"name\":\"📁 Project\",\"value\":\"'\"$(basename \"$(pwd)\")\"'\",\"inline\":true},{\"name\":\"⏰ Time\",\"value\":\"'\"$(date '+%H:%M:%S')\"'\",\"inline\":true}],\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"'\"}]}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then MESSAGE='{\"embeds\":[{\"title\":\"🔔 Claude Code Notification\",\"color\":3066993,\"fields\":[{\"name\":\"📁 Project\",\"value\":\"'\"$(basename \"$(pwd)\")\"'\",\"inline\":true},{\"name\":\"⏰ Time\",\"value\":\"'\"$(date '+%H:%M:%S')\"'\",\"inline\":true},{\"name\":\"💬 Status\",\"value\":\"Waiting for user input or permission\",\"inline\":false}],\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"'\"}]}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Send Discord notifications when Claude Code finishes working. Requires DISCORD_WEBHOOK_URL environment variable. Get webhook URL from Discord Server Settings -> Integrations -> Webhooks.",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then MESSAGE='{\"content\":\"🤖 Claude Code finished working at $(date '+%Y-%m-%d %H:%M:%S')\"}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send Discord notification\"; else echo \"⚠️ Discord notification skipped: Set DISCORD_WEBHOOK_URL environment variable\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$DISCORD_WEBHOOK_URL\" ]]; then MESSAGE='{\"content\":\"🎯 Claude Code subagent completed task at $(date '+%Y-%m-%d %H:%M:%S')\"}'; curl -s -X POST \"$DISCORD_WEBHOOK_URL\" -H \"Content-Type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send Discord notification\"; else echo \"⚠️ Discord notification skipped: Set DISCORD_WEBHOOK_URL environment variable\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"description": "Play games while Claude plans. Launches a browser game (Dino Runner, Snake, or Flappy Bird) automatically when Claude enters Plan Mode via EnterPlanMode or /plan. A live activity panel shows Claude's tool calls in real time. The game pauses when Claude needs input (AskUserQuestion) and shows a completion screen with confetti when the plan is done. Scores are saved locally and can be shared via a signed verification URL. Requires Node.js and curl. macOS only (uses 'open' to launch browser). Install globally so it works across all projects.",
|
||||
"tags": ["plan-mode", "gaming", "automation", "fun", "productivity", "global", "macos"],
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "plan-mode-game/start.sh",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/start.sh",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/activity.sh",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/activity.sh",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/plan-detect.sh",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/plan-detect.sh",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/announce.sh",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/announce.sh",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/needs-input.sh",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/needs-input.sh",
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/server.js",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/server.js",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
"source": "plan-mode-game/index.html",
|
||||
"destination": "~/.claude/scripts/plan-mode-game/index.html",
|
||||
"executable": false
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "EnterPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $HOME/.claude/scripts/plan-mode-game/start.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob|Grep|WebFetch|WebSearch|Task|Bash|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $HOME/.claude/scripts/plan-mode-game/activity.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $HOME/.claude/scripts/plan-mode-game/plan-detect.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $HOME/.claude/scripts/plan-mode-game/needs-input.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $HOME/.claude/scripts/plan-mode-game/announce.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Dino game activity hook - FAST PATH: exit immediately if not in plan mode
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FLAG="$SCRIPT_DIR/.plan-active"
|
||||
|
||||
# Fast exit if not in plan mode (no stdin read, no subprocess)
|
||||
[ ! -f "$FLAG" ] && exit 0
|
||||
|
||||
# We're in plan mode - read stdin and send activity
|
||||
INPUT=$(cat)
|
||||
curl -s --max-time 1 -X POST "http://localhost:3456/activity" \
|
||||
-H "Content-Type: application/json" -d "$INPUT" > /dev/null 2>&1 || true
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Send "Claude Termino" announcement, remove flag, kill server safely
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$SCRIPT_DIR/server.pid"
|
||||
FLAG="$SCRIPT_DIR/.plan-active"
|
||||
PORT=3456
|
||||
|
||||
# Send announcement
|
||||
curl -s -X POST "http://localhost:$PORT/done" -d "Claude Termino" > /dev/null 2>&1
|
||||
|
||||
# Remove plan-active flag
|
||||
rm -f "$FLAG"
|
||||
|
||||
# Safe kill: save current PID and verify before killing
|
||||
TARGET_PID=$(cat "$PID_FILE" 2>/dev/null)
|
||||
if [ -n "$TARGET_PID" ]; then
|
||||
(sleep 10 && [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE" 2>/dev/null)" = "$TARGET_PID" ] && kill "$TARGET_PID" 2>/dev/null && rm -f "$PID_FILE") &
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Pause game when Claude needs user input
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FLAG="$SCRIPT_DIR/.plan-active"
|
||||
|
||||
# Only send pause if game is active
|
||||
[ ! -f "$FLAG" ] && exit 0
|
||||
|
||||
curl -s --max-time 1 -X POST "http://localhost:3456/pause" > /dev/null 2>&1 || true
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Detects Write to ~/.claude/plans/ and starts dino game (for /plan command)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FLAG="$SCRIPT_DIR/.plan-active"
|
||||
|
||||
# Already in plan mode? Just forward to activity hook
|
||||
if [ -f "$FLAG" ]; then
|
||||
INPUT=$(cat)
|
||||
curl -s --max-time 1 -X POST "http://localhost:3456/activity" \
|
||||
-H "Content-Type: application/json" -d "$INPUT" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read stdin to check if this Write is to the plans directory
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
|
||||
|
||||
if echo "$FILE_PATH" | grep -q "/.claude/plans/"; then
|
||||
# Plan mode detected! Start the game
|
||||
bash "$SCRIPT_DIR/start.sh"
|
||||
# Send this Write as first activity
|
||||
curl -s --max-time 1 -X POST "http://localhost:3456/activity" \
|
||||
-H "Content-Type: application/json" -d "$INPUT" > /dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PORT = 3456;
|
||||
const PID_FILE = path.join(__dirname, 'server.pid');
|
||||
const SECRET_FILE = path.join(__dirname, '.secret');
|
||||
const sseClients = new Set();
|
||||
|
||||
function getSecret() {
|
||||
try {
|
||||
return fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||
} catch {
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
const hmacSecret = getSecret();
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// Serve the game
|
||||
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
||||
const htmlPath = path.join(__dirname, 'index.html');
|
||||
fs.readFile(htmlPath, (err, data) => {
|
||||
if (err) { res.writeHead(500); return res.end('Error loading game'); }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(data);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE endpoint
|
||||
if (req.method === 'GET' && req.url === '/events') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
res.write('data: connected\n\n');
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger announcement
|
||||
if (req.method === 'POST' && req.url === '/done') {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
const message = body || 'Claude Termino';
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: done\ndata: ${message}\n\n`);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, clients: sseClients.size }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Activity feed from hooks
|
||||
if (req.method === 'POST' && req.url === '/activity') {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: activity\ndata: ${body}\n\n`);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause - Claude needs user input
|
||||
if (req.method === 'POST' && req.url === '/pause') {
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: pause\ndata: Claude needs you\n\n`);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
// Status - check if plan mode is active
|
||||
if (req.method === 'GET' && req.url === '/status') {
|
||||
const planActive = fs.existsSync(path.join(__dirname, '.plan-active'));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ planActive }));
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (req.method === 'GET' && req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ status: 'ok', clients: sseClients.size }));
|
||||
}
|
||||
|
||||
// Share scores - generate signed URL
|
||||
if (req.method === 'POST' && req.url === '/share') {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; if (body.length > 1024) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { dino, snake, flappy } = JSON.parse(body);
|
||||
const payload = { d: dino || 0, s: snake || 0, f: flappy || 0, t: Date.now() };
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
|
||||
const token = `${payloadB64}.${sig}`;
|
||||
const url = `http://localhost:${PORT}/v/${token}`;
|
||||
const text = [
|
||||
'Plan Mode Game Scores:',
|
||||
`\u{1F995} Dino Runner: ${payload.d} pts`,
|
||||
`\u{1F40D} Snake: ${payload.s} pts`,
|
||||
`\u{1F426} Flappy Bird: ${payload.f} pts`,
|
||||
'',
|
||||
`Verify → ${url}`
|
||||
].join('\n');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ url, text }));
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid request' }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify scores - render verification page
|
||||
if (req.method === 'GET' && req.url.startsWith('/v/')) {
|
||||
const token = req.url.slice(3);
|
||||
const dotIdx = token.lastIndexOf('.');
|
||||
let valid = false, payload = null;
|
||||
|
||||
if (dotIdx > 0) {
|
||||
const payloadB64 = token.slice(0, dotIdx);
|
||||
const sig = token.slice(dotIdx + 1);
|
||||
const expected = crypto.createHmac('sha256', hmacSecret).update(payloadB64).digest('hex').slice(0, 16);
|
||||
valid = sig === expected;
|
||||
try { payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { valid = false; }
|
||||
}
|
||||
|
||||
const ts = payload && payload.t ? new Date(payload.t).toLocaleString() : 'Unknown';
|
||||
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>${valid ? 'Verified' : 'Invalid'} - Plan Mode Game</title><style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#1a1a2e;color:#e5e7eb;font-family:'Courier New',monospace;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{text-align:center;max-width:420px;padding:40px;background:#12121f;border:1px solid #2d2d4e;border-radius:16px}
|
||||
.icon{width:100px;height:100px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 24px;font-size:48px}
|
||||
.icon.valid{background:linear-gradient(135deg,#059669,#10b981);box-shadow:0 0 40px rgba(16,185,129,0.3)}
|
||||
.icon.invalid{background:linear-gradient(135deg,#dc2626,#ef4444);box-shadow:0 0 40px rgba(239,68,68,0.3)}
|
||||
h1{font-size:28px;margin-bottom:8px;letter-spacing:1px}
|
||||
h1.valid{color:#10b981}
|
||||
h1.invalid{color:#ef4444}
|
||||
.subtitle{color:#6b7280;font-size:13px;margin-bottom:28px}
|
||||
table{width:100%;border-collapse:collapse;margin-bottom:20px}
|
||||
td{padding:10px 16px;border-bottom:1px solid #1e1e35;font-size:14px}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.game{color:#d1d5db;text-align:left}
|
||||
.pts{color:#a78bfa;font-weight:700;text-align:right;font-variant-numeric:tabular-nums}
|
||||
.meta{color:#4b5563;font-size:11px;letter-spacing:0.5px}
|
||||
.play-link{display:inline-block;margin-top:20px;color:#a78bfa;text-decoration:none;font-size:13px;letter-spacing:1px;padding:10px 24px;border:1px solid #7c3aed;border-radius:8px;transition:all 0.2s}
|
||||
.play-link:hover{background:#7c3aed;color:#fff}
|
||||
</style></head><body><div class="card">
|
||||
<div class="icon ${valid ? 'valid' : 'invalid'}">${valid ? '✓' : '✗'}</div>
|
||||
<h1 class="${valid ? 'valid' : 'invalid'}">${valid ? 'VERIFIED' : 'INVALID'}</h1>
|
||||
<p class="subtitle">${valid ? 'These scores are authentic' : 'This score link has been tampered with'}</p>
|
||||
${valid && payload ? `<table>
|
||||
<tr><td class="game">\u{1F995} Dino Runner</td><td class="pts">${payload.d} pts</td></tr>
|
||||
<tr><td class="game">\u{1F40D} Snake</td><td class="pts">${payload.s} pts</td></tr>
|
||||
<tr><td class="game">\u{1F426} Flappy Bird</td><td class="pts">${payload.f} pts</td></tr>
|
||||
</table>
|
||||
<p class="meta">Shared on ${ts}</p>` : ''}
|
||||
<a class="play-link" href="/">Play Plan Mode Game</a>
|
||||
</div></body></html>`;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
// Handle port conflicts
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Port ${PORT} already in use. Exiting.`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
fs.writeFileSync(PID_FILE, String(process.pid));
|
||||
console.log(`Plan Mode Game server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// SSE heartbeat - keeps connections alive and cleans dead clients
|
||||
setInterval(() => {
|
||||
for (const client of sseClients) {
|
||||
try { client.write(': heartbeat\n\n'); }
|
||||
catch { sseClients.delete(client); }
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('SIGTERM', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
|
||||
function cleanup() {
|
||||
try { fs.unlinkSync(PID_FILE); } catch {}
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Start dino game server and open browser
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$SCRIPT_DIR/server.pid"
|
||||
FLAG="$SCRIPT_DIR/.plan-active"
|
||||
PORT=3456
|
||||
|
||||
# Create plan-active flag
|
||||
touch "$FLAG"
|
||||
|
||||
# Check if server is already running
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
# Server running, just open browser
|
||||
open "http://localhost:$PORT" 2>/dev/null
|
||||
exit 0
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start server in background
|
||||
nohup node "$SCRIPT_DIR/server.js" > /dev/null 2>&1 &
|
||||
|
||||
# Wait for server to be ready
|
||||
for i in {1..20}; do
|
||||
if curl -s "http://localhost:$PORT/health" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
# Open browser
|
||||
open "http://localhost:$PORT" 2>/dev/null
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Send desktop notifications when Claude Code finishes working or needs user input. Works on macOS and Linux systems.",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if command -v osascript >/dev/null 2>&1; then osascript -e 'display notification \"Claude has finished working\" with title \"Claude Code\"'; elif command -v notify-send >/dev/null 2>&1; then notify-send 'Claude Code' \"Claude has finished working\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if command -v osascript >/dev/null 2>&1; then osascript -e 'display notification \"Claude needs your input\" with title \"Claude Code\" sound name \"Ping\"'; elif command -v notify-send >/dev/null 2>&1; then notify-send -u critical 'Claude Code' \"Claude needs your input\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "Send detailed Slack notifications with session information when Claude Code finishes. Includes working directory, session duration, and system info. Requires SLACK_WEBHOOK_URL environment variable.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then echo \"$(date +%s)\" > ~/.claude/session_start.tmp; PROJECT_DIR=\"$(basename \"$(pwd)\")\"; MESSAGE='{\"blocks\":[{\"type\":\"header\",\"text\":{\"type\":\"plain_text\",\"text\":\"🚀 Claude Code Session Started\"}},{\"type\":\"section\",\"fields\":[{\"type\":\"mrkdwn\",\"text\":\"*📁 Project:*\\n'\"$PROJECT_DIR\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*⏰ Time:*\\n'\"$(date '+%H:%M:%S')\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*📅 Date:*\\n'\"$(date '+%Y-%m-%d')\"'\"}]}]}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then END_TIME=\"$(date +%s)\"; if [[ -f ~/.claude/session_start.tmp ]]; then START_TIME=\"$(cat ~/.claude/session_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; DURATION_TEXT=\"${MINUTES}m ${SECONDS}s\"; rm -f ~/.claude/session_start.tmp; else DURATION_TEXT=\"Unknown\"; fi; PROJECT_DIR=\"$(basename \"$(pwd)\")\"; MEMORY_MB=\"$(ps -o rss= -p $$ 2>/dev/null | awk '{print int($1/1024)}' || echo 'N/A')\"; MESSAGE='{\"blocks\":[{\"type\":\"header\",\"text\":{\"type\":\"plain_text\",\"text\":\"✅ Claude Code Session Completed\"}},{\"type\":\"section\",\"fields\":[{\"type\":\"mrkdwn\",\"text\":\"*📁 Project:*\\n'\"$PROJECT_DIR\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*⏱️ Duration:*\\n'\"$DURATION_TEXT\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*💾 Memory Used:*\\n'\"${MEMORY_MB}\"'MB\"},{\"type\":\"mrkdwn\",\"text\":\"*⏰ Finished:*\\n'\"$(date '+%H:%M:%S')\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*📅 Date:*\\n'\"$(date '+%Y-%m-%d')\"'\"}]}]}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send detailed Slack notification\"; else echo \"⚠️ Detailed Slack notification skipped: Set SLACK_WEBHOOK_URL\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Send Slack notifications when Claude Code encounters long-running operations or when tools take significant time. Helps monitor productivity and catch potential issues. Requires SLACK_WEBHOOK_URL environment variable.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then echo \"$(date +%s)\" > ~/.claude/bash_start.tmp; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" && -f ~/.claude/bash_start.tmp ]]; then END_TIME=\"$(date +%s)\"; START_TIME=\"$(cat ~/.claude/bash_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; rm -f ~/.claude/bash_start.tmp; if [[ $DURATION -gt 30 ]]; then MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; MESSAGE='{\"blocks\":[{\"type\":\"header\",\"text\":{\"type\":\"plain_text\",\"text\":\"⚠️ Long Bash Operation\"}},{\"type\":\"section\",\"fields\":[{\"type\":\"mrkdwn\",\"text\":\"*⏱️ Duration:*\\n'\"${MINUTES}\"'m '\"${SECONDS}\"'s\"},{\"type\":\"mrkdwn\",\"text\":\"*📁 Project:*\\n'\"$(basename \"$(pwd)\")\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*⏰ Time:*\\n'\"$(date '+%H:%M:%S')\"'\"}]}]}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then MESSAGE='{\"blocks\":[{\"type\":\"header\",\"text\":{\"type\":\"plain_text\",\"text\":\"🔔 Claude Code Notification\"}},{\"type\":\"section\",\"fields\":[{\"type\":\"mrkdwn\",\"text\":\"*📁 Project:*\\n'\"$(basename \"$(pwd)\")\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*⏰ Time:*\\n'\"$(date '+%H:%M:%S')\"'\"},{\"type\":\"mrkdwn\",\"text\":\"*💬 Status:*\\nWaiting for user input or permission\"}]}]}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Send Slack notifications when Claude Code finishes working. Requires SLACK_WEBHOOK_URL environment variable. Get webhook URL from Slack App settings -> Incoming Webhooks.",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then MESSAGE='{\"text\":\"🤖 Claude Code finished working at $(date '+%Y-%m-%d %H:%M:%S')\"}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send Slack notification\"; else echo \"⚠️ Slack notification skipped: Set SLACK_WEBHOOK_URL environment variable\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$SLACK_WEBHOOK_URL\" ]]; then MESSAGE='{\"text\":\"🎯 Claude Code subagent completed task at $(date '+%Y-%m-%d %H:%M:%S')\"}'; curl -s -X POST \"$SLACK_WEBHOOK_URL\" -H \"Content-type: application/json\" -d \"$MESSAGE\" >/dev/null 2>&1 || echo \"Failed to send Slack notification\"; else echo \"⚠️ Slack notification skipped: Set SLACK_WEBHOOK_URL environment variable\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "Send detailed Telegram notifications with session information when Claude Code finishes. Includes working directory, session duration, and system info. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then echo \"$(date +%s)\" > ~/.claude/session_start.tmp; PROJECT_DIR=\"$(basename \"$(pwd)\")\" && MESSAGE=\"🚀 <b>Claude Code Session Started</b>%0A📁 Project: $PROJECT_DIR%0A⏰ Time: $(date '+%H:%M:%S')%0A📅 Date: $(date '+%Y-%m-%d')\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then END_TIME=\"$(date +%s)\"; if [[ -f ~/.claude/session_start.tmp ]]; then START_TIME=\"$(cat ~/.claude/session_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; DURATION_TEXT=\"${MINUTES}m ${SECONDS}s\"; rm -f ~/.claude/session_start.tmp; else DURATION_TEXT=\"Unknown\"; fi; PROJECT_DIR=\"$(basename \"$(pwd)\")\"; MEMORY_MB=\"$(ps -o rss= -p $$ 2>/dev/null | awk '{print int($1/1024)}' || echo 'N/A')\"; MESSAGE=\"✅ <b>Claude Code Session Completed</b>%0A📁 Project: $PROJECT_DIR%0A⏱️ Duration: $DURATION_TEXT%0A💾 Memory Used: ${MEMORY_MB}MB%0A⏰ Finished: $(date '+%H:%M:%S')%0A📅 Date: $(date '+%Y-%m-%d')\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1 || echo \"Failed to send detailed Telegram notification\"; else echo \"⚠️ Detailed Telegram notification skipped: Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Send Telegram notifications when Claude Code encounters long-running operations or when tools take significant time. Helps monitor productivity and catch potential issues. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then echo \"$(date +%s)\" > ~/.claude/bash_start.tmp; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" && -f ~/.claude/bash_start.tmp ]]; then END_TIME=\"$(date +%s)\"; START_TIME=\"$(cat ~/.claude/bash_start.tmp)\"; DURATION=\"$((END_TIME - START_TIME))\"; rm -f ~/.claude/bash_start.tmp; if [[ $DURATION -gt 30 ]]; then MINUTES=\"$((DURATION / 60))\"; SECONDS=\"$((DURATION % 60))\"; MESSAGE=\"⚠️ <b>Long Bash Operation</b>%0A⏱️ Duration: ${MINUTES}m ${SECONDS}s%0A📁 Project: $(basename \"$(pwd)\")%0A⏰ Time: $(date '+%H:%M:%S')\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1; fi; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then MESSAGE=\"🔔 <b>Claude Code Notification</b>%0A📁 Project: $(basename \"$(pwd)\")%0A⏰ Time: $(date '+%H:%M:%S')%0A💬 Status: Waiting for user input or permission\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Send Telegram notifications when Claude Code finishes working. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables. Get bot token from @BotFather, get chat ID by messaging the bot and visiting https://api.telegram.org/bot<TOKEN>/getUpdates",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then MESSAGE=\"🤖 Claude Code finished working at $(date '+%Y-%m-%d %H:%M:%S')\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1 || echo \"Failed to send Telegram notification\"; else echo \"⚠️ Telegram notification skipped: Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$TELEGRAM_BOT_TOKEN\" && -n \"$TELEGRAM_CHAT_ID\" ]]; then MESSAGE=\"🎯 Claude Code subagent completed task at $(date '+%Y-%m-%d %H:%M:%S')\"; curl -s -X POST \"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage\" -d \"chat_id=$TELEGRAM_CHAT_ID\" -d \"text=$MESSAGE\" -d \"parse_mode=HTML\" >/dev/null 2>&1 || echo \"Failed to send Telegram notification\"; else echo \"⚠️ Telegram notification skipped: Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Send Telegram notification when a new PR is created via gh pr create. Includes PR URL and Vercel preview URL. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables. Optionally set VERCEL_PROJECT_NAME and VERCEL_TEAM_SLUG to construct the Vercel preview URL automatically.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/telegram-pr-webhook.py",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram PR Webhook Hook
|
||||
Sends a Telegram notification when a new PR is created via `gh pr create`.
|
||||
Includes the PR URL and the Vercel preview URL.
|
||||
|
||||
Required environment variables:
|
||||
TELEGRAM_BOT_TOKEN - Bot token from @BotFather
|
||||
TELEGRAM_CHAT_ID - Chat ID for notifications
|
||||
|
||||
Optional environment variables:
|
||||
VERCEL_PROJECT_NAME - Vercel project name (for preview URL)
|
||||
VERCEL_TEAM_SLUG - Vercel team slug (for preview URL)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def get_input():
|
||||
try:
|
||||
return json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def extract_pr_url(text):
|
||||
"""Extract GitHub PR URL from command output."""
|
||||
match = re.search(r"https://github\.com/[^\s]+/pull/\d+", text)
|
||||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def get_branch_name():
|
||||
"""Get the current git branch name."""
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "branch", "--show-current"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def build_vercel_preview_url(branch):
|
||||
"""Build Vercel preview URL from branch name and env vars."""
|
||||
project = os.environ.get("VERCEL_PROJECT_NAME", "")
|
||||
team = os.environ.get("VERCEL_TEAM_SLUG", "")
|
||||
|
||||
if not project or not team:
|
||||
return None
|
||||
|
||||
# Vercel slugifies branch names: lowercase, replace non-alphanumeric with -
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", branch.lower()).strip("-")
|
||||
return f"https://{project}-git-{slug}-{team}.vercel.app"
|
||||
|
||||
|
||||
def send_telegram(message):
|
||||
"""Send a message via Telegram Bot API."""
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
if not token or not chat_id:
|
||||
print(
|
||||
"Telegram notification skipped: "
|
||||
"Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
data = urllib.parse.urlencode(
|
||||
{"chat_id": chat_id, "text": message, "parse_mode": "HTML"}
|
||||
).encode("utf-8")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send Telegram notification: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
input_data = get_input()
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Only act on gh pr create commands
|
||||
if tool_name != "Bash" or "gh pr create" not in command:
|
||||
sys.exit(0)
|
||||
|
||||
# Extract PR URL from tool response
|
||||
tool_response = input_data.get("tool_response", "")
|
||||
if isinstance(tool_response, dict):
|
||||
tool_response = tool_response.get("stdout", "") or json.dumps(tool_response)
|
||||
|
||||
pr_url = extract_pr_url(str(tool_response))
|
||||
if not pr_url:
|
||||
# No PR URL found — the command may have failed
|
||||
sys.exit(0)
|
||||
|
||||
# Build Vercel preview URL
|
||||
branch = get_branch_name()
|
||||
vercel_url = build_vercel_preview_url(branch) if branch else None
|
||||
|
||||
# Compose Telegram message
|
||||
lines = [
|
||||
"<b>New Pull Request Created</b>",
|
||||
"",
|
||||
f"<b>PR:</b> <a href=\"{pr_url}\">{pr_url}</a>",
|
||||
]
|
||||
|
||||
if vercel_url:
|
||||
lines.append(f"<b>Preview:</b> <a href=\"{vercel_url}\">{vercel_url}</a>")
|
||||
else:
|
||||
lines.append(
|
||||
"<b>Preview:</b> Check the PR checks tab for the Vercel deployment URL"
|
||||
)
|
||||
|
||||
if branch:
|
||||
lines.append(f"<b>Branch:</b> <code>{branch}</code>")
|
||||
|
||||
message = "\n".join(lines)
|
||||
send_telegram(message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Automatically trigger Vercel deployments when code changes are committed, with environment-specific deployment strategies and rollback on failure. Setup: Export environment variables 'export VERCEL_TOKEN=your_token' and 'export VERCEL_PROJECT_ID=your_project_id' (get your token from vercel.com/account/tokens and project ID from your Vercel dashboard). Hook triggers on PostToolUse for Write, Edit, and MultiEdit operations affecting source code files.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); TOOL_NAME=$(echo \"$input\" | jq -r \".tool_name\"); FILE_PATH=$(echo \"$input\" | jq -r \".tool_input.file_path // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [ \"$SUCCESS\" = \"true\" ] && [[ \"$FILE_PATH\" =~ \\.(js|jsx|ts|tsx|json|md|css|scss|html)$ ]] && [[ ! \"$FILE_PATH\" =~ node_modules ]] && [[ ! \"$FILE_PATH\" =~ \\.next ]] && [[ ! \"$FILE_PATH\" =~ \\.vercel ]]; then echo \"🚀 Code change detected in $FILE_PATH, checking for auto-deployment...\"; if [ -n \"$VERCEL_TOKEN\" ] && [ -n \"$VERCEL_PROJECT_ID\" ]; then BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"unknown\"); if [ \"$BRANCH\" = \"main\" ] || [ \"$BRANCH\" = \"master\" ]; then echo \"📦 Triggering production deployment on $BRANCH branch...\"; DEPLOY_RESULT=$(curl -s -X POST \"https://api.vercel.com/v13/deployments\" -H \"Authorization: Bearer $VERCEL_TOKEN\" -H \"Content-Type: application/json\" -d \"{\\\"name\\\":\\\"auto-deploy\\\",\\\"project\\\":\\\"$VERCEL_PROJECT_ID\\\",\\\"gitSource\\\":{\\\"type\\\":\\\"github\\\",\\\"ref\\\":\\\"$BRANCH\\\"}}\"); DEPLOY_URL=$(echo \"$DEPLOY_RESULT\" | jq -r \".url // empty\"); if [ -n \"$DEPLOY_URL\" ]; then echo \"✅ Deployment initiated: https://$DEPLOY_URL\"; echo \"🔄 Monitor status: vercel ls --limit 1\"; else echo \"❌ Deployment failed. Check Vercel logs.\"; fi; elif [ \"$BRANCH\" = \"develop\" ] || [ \"$BRANCH\" = \"staging\" ]; then echo \"🎯 Triggering preview deployment on $BRANCH branch...\"; vercel --yes --force 2>/dev/null && echo \"✅ Preview deployment completed\" || echo \"❌ Preview deployment failed\"; else echo \"ℹ️ Auto-deployment disabled for branch: $BRANCH (only main/master/develop/staging)\"; fi; else echo \"⚠️ VERCEL_TOKEN or VERCEL_PROJECT_ID not configured. Set environment variables to enable auto-deployment.\"; fi; else echo \"ℹ️ File $FILE_PATH not eligible for auto-deployment (non-source file or unsuccessful operation)\"; fi'",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "Synchronize environment variables between local development and Vercel deployments, ensuring consistency across all environments. Detects changes to .env files and provides options to sync with Vercel, validates environment variable format, and ensures required variables are present. Setup: Export 'export VERCEL_TOKEN=your_token' (get from vercel.com/account/tokens).",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); FILE_PATH=$(echo \"$input\" | jq -r \".tool_input.file_path // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [ \"$SUCCESS\" = \"true\" ] && [[ \"$FILE_PATH\" =~ \\.env ]]; then echo \"🔐 Environment file change detected: $FILE_PATH\"; if [ -n \"$VERCEL_TOKEN\" ]; then echo \"🔄 Environment Sync available - Vercel token configured\"; ENV_TYPE=\"development\"; if [[ \"$FILE_PATH\" =~ \\.env\\.production ]]; then ENV_TYPE=\"production\"; elif [[ \"$FILE_PATH\" =~ \\.env\\.preview ]] || [[ \"$FILE_PATH\" =~ \\.env\\.staging ]]; then ENV_TYPE=\"preview\"; fi; echo \"📋 Environment type detected: $ENV_TYPE\"; if [ -f \"$FILE_PATH\" ]; then echo \"🔍 Validating environment variables in $FILE_PATH...\"; VALIDATION_ISSUES=0; while IFS= read -r line; do if [[ \"$line\" =~ ^[A-Z_][A-Z0-9_]*= ]] && [[ ! \"$line\" =~ ^# ]]; then VAR_NAME=$(echo \"$line\" | cut -d\"=\" -f1); VAR_VALUE=$(echo \"$line\" | cut -d\"=\" -f2-); if [[ \"$VAR_VALUE\" =~ ^[\\\"\\'].*[\\\"\\']$ ]]; then echo \"💡 $VAR_NAME: Quoted value detected (quotes will be included in value)\"; fi; if [[ \"$VAR_NAME\" =~ (SECRET|PRIVATE|KEY|TOKEN) ]] && [ ${#VAR_VALUE} -lt 16 ]; then echo \"⚠️ $VAR_NAME: Secret appears to be too short (${#VAR_VALUE} chars)\" >&2; ((VALIDATION_ISSUES++)); fi; if [[ \"$VAR_VALUE\" == \"your-\"* ]] || [[ \"$VAR_VALUE\" == \"change-me\"* ]] || [[ \"$VAR_VALUE\" == \"replace-\"* ]]; then echo \"❌ $VAR_NAME: Placeholder value detected\" >&2; ((VALIDATION_ISSUES++)); fi; elif [[ \"$line\" =~ ^[A-Za-z] ]] && [[ ! \"$line\" =~ ^# ]] && [ -n \"$line\" ]; then echo \"⚠️ Invalid environment variable format: $line\" >&2; ((VALIDATION_ISSUES++)); fi; done < \"$FILE_PATH\"; if [ $VALIDATION_ISSUES -eq 0 ]; then echo \"✅ Environment validation passed\"; VAR_COUNT=$(grep -c \"^[A-Z_][A-Z0-9_]*=\" \"$FILE_PATH\" 2>/dev/null || echo \"0\"); echo \"📊 Found $VAR_COUNT environment variables\"; echo \"💡 Sync options:\"; echo \" • Manual sync: vercel env pull .env.local\"; echo \" • Push to Vercel: vercel env add [name] [environment]\"; echo \" • Bulk sync: Use vercel-env-sync command if available\"; echo \"🔒 Security reminder: Never commit secrets to version control\"; else echo \"❌ Found $VALIDATION_ISSUES validation issues\" >&2; echo \"🚨 Environment sync blocked due to validation errors\" >&2; exit 2; fi; else echo \"❌ File $FILE_PATH not found\"; fi; else echo \"⚠️ VERCEL_TOKEN not configured. Set environment variable to enable sync features.\"; echo \"💡 Get token from: https://vercel.com/account/tokens\"; echo \"💡 Export with: export VERCEL_TOKEN=your_token\"; fi; else echo \"ℹ️ Environment sync skipped (not an .env file or failed operation)\"; fi'",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'echo \"🔐 Environment Sync Status Check...\"; if [ -n \"$VERCEL_TOKEN\" ]; then echo \"✅ Vercel token configured\"; if command -v vercel >/dev/null 2>&1; then echo \"✅ Vercel CLI available\"; PROJECT_STATUS=$(vercel project ls 2>/dev/null | head -1); if [[ \"$PROJECT_STATUS\" =~ \"No projects found\" ]]; then echo \"⚠️ No Vercel project linked to current directory\"; echo \"💡 Run: vercel link\"; else echo \"✅ Vercel project linked\"; fi; else echo \"⚠️ Vercel CLI not installed\"; echo \"💡 Install with: npm i -g vercel\"; fi; if [ -f \".env.example\" ]; then echo \"📋 .env.example found - template available\"; fi; ENV_FILES=($(ls .env* 2>/dev/null | grep -v .env.example || true)); if [ ${#ENV_FILES[@]} -gt 0 ]; then echo \"📁 Environment files found: ${ENV_FILES[*]}\"; for file in \"${ENV_FILES[@]}\"; do VAR_COUNT=$(grep -c \"^[A-Z_][A-Z0-9_]*=\" \"$file\" 2>/dev/null || echo \"0\"); echo \" $file: $VAR_COUNT variables\"; done; else echo \"ℹ️ No .env files found\"; fi; else echo \"⚠️ VERCEL_TOKEN not configured\"; echo \"💡 Environment sync features disabled\"; fi'",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Track file changes in a simple log. Records which files were modified and when for easy tracking of Claude Code activity.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo \"[$(date '+%Y-%m-%d %H:%M:%S')] File modified: $CLAUDE_TOOL_FILE_PATH\" >> ~/.claude/changes.log"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo \"[$(date '+%Y-%m-%d %H:%M:%S')] File created: $CLAUDE_TOOL_FILE_PATH\" >> ~/.claude/changes.log"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Log all Claude Code commands to a file for audit and debugging purposes. Simple logging that records tool usage with timestamps.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo \"[$(date)] Tool: $CLAUDE_TOOL_NAME | File: $CLAUDE_TOOL_FILE_PATH\" >> ~/.claude/command-log.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "Auto Debug Log Viewer. Opens a live-tailing debug log window when Claude Code starts with --debug or -d flag. The window closes automatically on session end. To keep the debug window open after session ends, set DEBUG_WINDOW_AUTO_CLOSE_DISABLE=1 in your settings.json. Tested on Intel Mac. Supports macOS, Linux, and Windows (Git Bash/Cygwin). Contributions from other platform users are welcome.",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/debug-window.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/debug-window.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
# Session Hook: Auto Debug Log Viewer
|
||||
#
|
||||
# Opens a live-tailing debug log window on SessionStart and
|
||||
# closes it on SessionEnd when Claude Code runs with --debug or -d flag.
|
||||
#
|
||||
# Events:
|
||||
# - SessionStart: Opens terminal window with tail -f on debug log
|
||||
# - SessionEnd: Terminates tail process and closes window
|
||||
#
|
||||
# Requirements:
|
||||
# - Only runs when --debug or -d flag was used to start the session
|
||||
# - Debug log file must exist at ~/.claude/debug/{session_id}.txt
|
||||
#
|
||||
# Configuration (in ~/.claude/settings.json):
|
||||
# To keep debug window open after session ends:
|
||||
# { "env": { "DEBUG_WINDOW_AUTO_CLOSE_DISABLE": "1" } }
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# check_debug_flag: Traverse parent process tree to find --debug or -d flag
|
||||
#
|
||||
# Why: The hook script runs as a child process of Claude Code.
|
||||
# We walk up the process tree to check if Claude was started with
|
||||
# debug flag, since only then should we manage the debug window.
|
||||
#
|
||||
# Returns: 0 if debug flag found, 1 otherwise
|
||||
#-----------------------------------------------------------------------
|
||||
check_debug_flag() {
|
||||
local pid=$$
|
||||
while [[ $pid -ne 1 ]]; do
|
||||
local cmdline
|
||||
# Linux stores cmdline in /proc, macOS requires ps command
|
||||
if [[ -f "/proc/$pid/cmdline" ]]; then
|
||||
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
|
||||
else
|
||||
cmdline=$(ps -p "$pid" -o args= 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Match --debug or -d as standalone flags (not part of another word)
|
||||
[[ "$cmdline" =~ (^|[[:space:]])(--debug|-d)($|[[:space:]]) ]] && return 0
|
||||
|
||||
# Move to parent process
|
||||
local ppid
|
||||
ppid=$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ')
|
||||
[[ -z "$ppid" || "$ppid" == "$pid" ]] && break
|
||||
pid=$ppid
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Exit early if debug flag not found
|
||||
check_debug_flag || exit 0
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# Parse JSON input from stdin
|
||||
#
|
||||
# Claude Code passes session info via stdin as JSON:
|
||||
# {"session_id":"uuid","hook_event_name":"SessionStart|SessionEnd",...}
|
||||
#
|
||||
# We use jq if available, otherwise fall back to sed for portability
|
||||
#-----------------------------------------------------------------------
|
||||
INPUT=$(cat)
|
||||
|
||||
if command -v jq &> /dev/null; then
|
||||
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
|
||||
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
|
||||
else
|
||||
SESSION_ID=$(echo "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
HOOK_EVENT=$(echo "$INPUT" | sed -n 's/.*"hook_event_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
fi
|
||||
|
||||
# Exit if session_id missing
|
||||
[[ -z "$SESSION_ID" || "$SESSION_ID" == "null" ]] && exit 1
|
||||
|
||||
DEBUG_LOG="$HOME/.claude/debug/${SESSION_ID}.txt"
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# SessionStart: Open debug log in a new terminal window
|
||||
#-----------------------------------------------------------------------
|
||||
open_debug_window() {
|
||||
# Wait for debug log file creation (max 5 seconds)
|
||||
# Why: Claude Code may not have created the file yet when hook runs
|
||||
for i in {1..5}; do
|
||||
[[ -f "$DEBUG_LOG" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ ! -f "$DEBUG_LOG" ]] && exit 1
|
||||
|
||||
# Platform-specific terminal window opening
|
||||
# Opens: tail -n 1000 -f $DEBUG_LOG (last 1000 lines + follow)
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
osascript -e "tell application \"Terminal\" to do script \"tail -n 1000 -f '$DEBUG_LOG'\""
|
||||
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
if command -v gnome-terminal &> /dev/null; then
|
||||
gnome-terminal -- tail -n 1000 -f "$DEBUG_LOG"
|
||||
elif command -v konsole &> /dev/null; then
|
||||
konsole -e tail -n 1000 -f "$DEBUG_LOG"
|
||||
elif command -v xterm &> /dev/null; then
|
||||
xterm -e tail -n 1000 -f "$DEBUG_LOG" &
|
||||
fi
|
||||
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
|
||||
start cmd /c "tail -n 1000 -f '$DEBUG_LOG'"
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# SessionEnd: Close debug log window
|
||||
#-----------------------------------------------------------------------
|
||||
close_debug_window() {
|
||||
# Allow users to disable auto-close via environment variable
|
||||
[[ "$DEBUG_WINDOW_AUTO_CLOSE_DISABLE" == "1" ]] && exit 0
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# Find tail process by matching the exact debug log path
|
||||
# ps output: PID TTY COMMAND
|
||||
TAIL_INFO=$(ps -eo pid,tty,args | grep "tail.*${DEBUG_LOG}" | grep -v grep | head -1)
|
||||
TAIL_PID=$(echo "$TAIL_INFO" | awk '{print $1}')
|
||||
TAIL_TTY=$(echo "$TAIL_INFO" | awk '{print $2}')
|
||||
|
||||
[[ -z "$TAIL_TTY" || "$TAIL_TTY" == "??" ]] && exit 0
|
||||
|
||||
# Kill tail first to prevent "terminate running process?" dialog
|
||||
[[ -n "$TAIL_PID" ]] && kill "$TAIL_PID" 2>/dev/null && sleep 0.2
|
||||
|
||||
# Close Terminal window by matching TTY
|
||||
osascript -e "
|
||||
tell application \"Terminal\"
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if tty of t is \"/dev/$TAIL_TTY\" then
|
||||
close w
|
||||
return
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
" 2>/dev/null
|
||||
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Killing tail process is usually sufficient on Linux
|
||||
pkill -f "tail.*${DEBUG_LOG}"
|
||||
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
|
||||
# Note: taskkill /FI does not support COMMANDLINE filter
|
||||
# Use ps and kill instead
|
||||
TAIL_PID=$(ps -s | grep "tail.*${DEBUG_LOG}" | grep -v grep | awk '{print $1}' | head -1)
|
||||
[[ -n "$TAIL_PID" ]] && kill "$TAIL_PID" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# Event dispatcher
|
||||
#-----------------------------------------------------------------------
|
||||
case "$HOOK_EVENT" in
|
||||
SessionStart) open_debug_window ;;
|
||||
SessionEnd) close_debug_window ;;
|
||||
esac
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Log all file edits to a project-local audit file with timestamps. Records every Edit tool usage to .claude/edit-log.txt for tracking changes across sessions. Requires jq.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "jq -r '.tool_input.file_path' | xargs -I FILE sh -c 'echo \"$(date +%Y-%m-%dT%H:%M:%S): Edit FILE\" >> \"$CLAUDE_PROJECT_DIR/.claude/edit-log.txt\"'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically backup files before editing. Creates timestamped backups in a .backups directory when files are modified.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$CLAUDE_TOOL_FILE_PATH\" && -f \"$CLAUDE_TOOL_FILE_PATH\" ]]; then mkdir -p .backups && cp \"$CLAUDE_TOOL_FILE_PATH\" \".backups/$(basename \"$CLAUDE_TOOL_FILE_PATH\").$(date +%Y%m%d_%H%M%S).bak\"; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically run linting tools after file modifications. Supports ESLint for JavaScript/TypeScript, Pylint for Python, and RuboCop for Ruby.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.js || \"$CLAUDE_TOOL_FILE_PATH\" == *.ts || \"$CLAUDE_TOOL_FILE_PATH\" == *.jsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.tsx ]]; then npx eslint \"$CLAUDE_TOOL_FILE_PATH\" --fix 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then pylint \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.rb ]]; then rubocop \"$CLAUDE_TOOL_FILE_PATH\" --auto-correct 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Enforce Next.js best practices, proper file structure, component patterns, and TypeScript usage with automated code reviews and suggestions. Validates Next.js App Router conventions, Server/Client component patterns, proper imports, and TypeScript usage. Provides real-time feedback on code quality and adherence to Next.js best practices.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); FILE_PATH=$(echo \"$input\" | jq -r \".tool_input.file_path // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [ \"$SUCCESS\" = \"true\" ] && [[ \"$FILE_PATH\" =~ \\.(js|jsx|ts|tsx)$ ]] && [[ ! \"$FILE_PATH\" =~ node_modules ]]; then echo \"🔍 Next.js Code Quality Enforcer: Reviewing $FILE_PATH...\"; ISSUES=0; if [ -f \"$FILE_PATH\" ]; then if [[ \"$FILE_PATH\" =~ app/.* ]]; then echo \"📁 App Router file detected: $FILE_PATH\"; if [[ \"$FILE_PATH\" =~ page\\.(js|jsx|ts|tsx)$ ]] && ! grep -q \"export default function\" \"$FILE_PATH\" 2>/dev/null && ! grep -q \"export default async function\" \"$FILE_PATH\" 2>/dev/null; then echo \"❌ Page component must export default function\" >&2; ((ISSUES++)); fi; if [[ \"$FILE_PATH\" =~ layout\\.(js|jsx|ts|tsx)$ ]] && ! grep -q \"children\" \"$FILE_PATH\" 2>/dev/null; then echo \"❌ Layout component should accept children prop\" >&2; ((ISSUES++)); fi; if [[ \"$FILE_PATH\" =~ page\\.(js|jsx|ts|tsx)$ ]] && ! grep -q \"Metadata\" \"$FILE_PATH\" 2>/dev/null && ! grep -q \"metadata\" \"$FILE_PATH\" 2>/dev/null; then echo \"⚠️ Consider adding metadata export for SEO\"; fi; if grep -q \"use client\" \"$FILE_PATH\" 2>/dev/null; then echo \"🖥️ Client Component detected\"; if ! grep -E \"(useState|useEffect|onClick|onChange|onSubmit)\" \"$FILE_PATH\" 2>/dev/null; then echo \"⚠️ Client component without interactivity - consider Server Component\"; fi; else echo \"🚀 Server Component (default)\"; if grep -E \"(useState|useEffect|onClick|onChange|onSubmit)\" \"$FILE_PATH\" 2>/dev/null; then echo \"❌ Interactive features in Server Component - add \\\"use client\\\" directive\" >&2; ((ISSUES++)); fi; fi; fi; if [[ \"$FILE_PATH\" =~ \\.(jsx|tsx)$ ]]; then if ! grep -q \"import.*React\" \"$FILE_PATH\" 2>/dev/null && grep -q \"<\" \"$FILE_PATH\" 2>/dev/null; then echo \"⚠️ JSX without React import (Next.js 17+ handles this automatically)\"; fi; if ! grep -q \"FC\\|FunctionComponent\" \"$FILE_PATH\" 2>/dev/null && grep -q \"props\" \"$FILE_PATH\" 2>/dev/null && [[ \"$FILE_PATH\" =~ \\.tsx$ ]]; then echo \"💡 Consider using React.FC or explicit prop types for TypeScript\"; fi; fi; if [[ \"$FILE_PATH\" =~ \\.js$ ]] && [ -f \"tsconfig.json\" ]; then echo \"📝 JavaScript file in TypeScript project: $FILE_PATH\"; echo \"💡 Consider migrating to TypeScript for better type safety\"; fi; if grep -q \"next/image\" \"$FILE_PATH\" 2>/dev/null; then echo \"✅ Using next/image for optimized images\"; elif grep -q \"<img\" \"$FILE_PATH\" 2>/dev/null; then echo \"🖼️ Regular <img> tag detected\"; echo \"💡 Consider using next/image for better performance\"; fi; if grep -q \"next/link\" \"$FILE_PATH\" 2>/dev/null; then echo \"✅ Using next/link for navigation\"; elif grep -q \"<a href=\" \"$FILE_PATH\" 2>/dev/null && ! grep -q \"http\" \"$FILE_PATH\" 2>/dev/null; then echo \"🔗 Regular <a> tag for internal links detected\"; echo \"💡 Use next/link for internal navigation\"; fi; if grep -q \"getServerSideProps\\|getStaticProps\" \"$FILE_PATH\" 2>/dev/null; then echo \"⚠️ Pages Router data fetching methods detected\"; echo \"💡 Consider migrating to App Router with Server Components\"; fi; if grep -q \"className=.*{\" \"$FILE_PATH\" 2>/dev/null; then echo \"🎨 Dynamic className detected\"; if ! grep -q \"clsx\\|classnames\\|cn(\" \"$FILE_PATH\" 2>/dev/null; then echo \"💡 Consider using clsx or similar utility for className concatenation\"; fi; fi; if [ $ISSUES -eq 0 ]; then echo \"✅ Code quality check passed for $FILE_PATH\"; else echo \"❌ Found $ISSUES code quality issues in $FILE_PATH\" >&2; exit 2; fi; else echo \"❌ File $FILE_PATH not found\"; fi; else echo \"ℹ️ Code quality check skipped (not a JavaScript/TypeScript file or failed operation)\"; fi'",
|
||||
"timeout": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Smart code formatting based on file type. Automatically formats code using Prettier, Black, gofmt, rustfmt, and other language-specific formatters.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.js || \"$CLAUDE_TOOL_FILE_PATH\" == *.ts || \"$CLAUDE_TOOL_FILE_PATH\" == *.jsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.tsx || \"$CLAUDE_TOOL_FILE_PATH\" == *.json || \"$CLAUDE_TOOL_FILE_PATH\" == *.css || \"$CLAUDE_TOOL_FILE_PATH\" == *.html ]]; then npx prettier --write \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then black \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.go ]]; then gofmt -w \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.rs ]]; then rustfmt \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.php ]]; then php-cs-fixer fix \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "Worktree Ghostty Layout. Opens a 3-panel Ghostty layout when creating worktrees: Claude Code (left) | lazygit (top-right) / yazi (bottom-right). Creates worktrees in a sibling directory (../worktrees/<repo>/<name>/) and cleans up on removal. macOS only. Requires: jq, Ghostty terminal, lazygit, yazi. Ghostty keybindings required: super+d = new_split:right, super+shift+d = new_split:down.",
|
||||
"hooks": {
|
||||
"WorktreeCreate": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-ghostty.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"WorktreeRemove": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-ghostty.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# Hook: Worktree Ghostty Layout
|
||||
#
|
||||
# Creates git worktrees in a sibling directory and opens a Ghostty terminal
|
||||
# layout with lazygit (top-right) and yazi (bottom-right).
|
||||
#
|
||||
# Events:
|
||||
# - WorktreeCreate: Creates worktree + opens Ghostty 3-panel layout
|
||||
# - WorktreeRemove: Removes worktree, branch, and empty directories
|
||||
#
|
||||
# Requirements:
|
||||
# - jq (JSON parsing)
|
||||
# - Ghostty terminal (macOS)
|
||||
# - lazygit (git TUI)
|
||||
# - yazi (file manager TUI)
|
||||
#
|
||||
# Ghostty keybindings required:
|
||||
# super+d = new_split:right
|
||||
# super+shift+d = new_split:down
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "jq is required but not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
|
||||
CWD=$(echo "$INPUT" | jq -r '.cwd')
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# WorktreeCreate: Create worktree in sibling dir + open Ghostty layout
|
||||
#-----------------------------------------------------------------------
|
||||
create_worktree() {
|
||||
local NAME
|
||||
NAME=$(echo "$INPUT" | jq -r '.name')
|
||||
|
||||
local REPO_NAME PARENT_DIR WORKTREE_DIR BRANCH_NAME
|
||||
REPO_NAME=$(basename "$CWD")
|
||||
PARENT_DIR=$(cd "$CWD/.." && pwd)
|
||||
WORKTREE_DIR="$PARENT_DIR/worktrees/$REPO_NAME/$NAME"
|
||||
BRANCH_NAME="worktree-$NAME"
|
||||
|
||||
# Detect default remote branch
|
||||
local DEFAULT_BRANCH
|
||||
DEFAULT_BRANCH=$(cd "$CWD" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
|
||||
: "${DEFAULT_BRANCH:=main}"
|
||||
|
||||
# Create git worktree in sibling directory
|
||||
mkdir -p "$PARENT_DIR/worktrees/$REPO_NAME" >&2
|
||||
cd "$CWD" || exit 1
|
||||
git fetch origin &>/dev/null || true
|
||||
git worktree add -b "$BRANCH_NAME" "$WORKTREE_DIR" "origin/$DEFAULT_BRANCH" >&2 || {
|
||||
echo "Failed to create worktree: $NAME" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Open Ghostty layout: Claude (left) | lazygit (top-right) / yazi (bottom-right)
|
||||
# Uses clipboard + Cmd+V for reliable text input (keystroke is unreliable for long paths)
|
||||
{
|
||||
sleep 1.5
|
||||
osascript <<APPLESCRIPT >/dev/null 2>&1
|
||||
-- Save current clipboard
|
||||
try
|
||||
set oldClip to the clipboard as text
|
||||
on error
|
||||
set oldClip to ""
|
||||
end try
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Ghostty"
|
||||
-- Split right (Cmd+D)
|
||||
keystroke "d" using {command down}
|
||||
delay 1.0
|
||||
|
||||
-- cd + lazygit
|
||||
set the clipboard to "cd '${WORKTREE_DIR}' && lazygit"
|
||||
keystroke "v" using {command down}
|
||||
delay 0.3
|
||||
key code 36
|
||||
delay 2.0
|
||||
|
||||
-- Split down (Cmd+Shift+D)
|
||||
keystroke "d" using {command down, shift down}
|
||||
delay 1.0
|
||||
|
||||
-- cd + yazi
|
||||
set the clipboard to "cd '${WORKTREE_DIR}' && yazi"
|
||||
keystroke "v" using {command down}
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
end tell
|
||||
|
||||
-- Restore clipboard
|
||||
delay 0.5
|
||||
set the clipboard to oldClip
|
||||
APPLESCRIPT
|
||||
} &>/dev/null &
|
||||
|
||||
# Output the worktree path (the ONLY stdout Claude Code reads)
|
||||
echo "$WORKTREE_DIR"
|
||||
}
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# WorktreeRemove: Clean up worktree, branch, and empty directories
|
||||
#-----------------------------------------------------------------------
|
||||
remove_worktree() {
|
||||
local WORKTREE_PATH
|
||||
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path')
|
||||
|
||||
[ ! -d "$WORKTREE_PATH" ] && exit 0
|
||||
|
||||
# Find main repo (first entry in worktree list)
|
||||
local MAIN_REPO BRANCH_NAME
|
||||
MAIN_REPO=$(git -C "$WORKTREE_PATH" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')
|
||||
BRANCH_NAME="worktree-$(basename "$WORKTREE_PATH")"
|
||||
|
||||
# Remove worktree and branch
|
||||
cd "$MAIN_REPO" 2>/dev/null || exit 0
|
||||
git worktree remove "$WORKTREE_PATH" --force 2>/dev/null || rm -rf "$WORKTREE_PATH"
|
||||
git branch -D "$BRANCH_NAME" 2>/dev/null
|
||||
|
||||
# Clean up empty parent directories
|
||||
rmdir "$(dirname "$WORKTREE_PATH")" 2>/dev/null
|
||||
}
|
||||
|
||||
#-----------------------------------------------------------------------
|
||||
# Event dispatcher
|
||||
#-----------------------------------------------------------------------
|
||||
case "$HOOK_EVENT" in
|
||||
WorktreeCreate) create_worktree ;;
|
||||
WorktreeRemove) remove_worktree ;;
|
||||
esac
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically stage modified files with git add after editing. Helps maintain a clean git workflow by staging changes as they're made.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -n \"$CLAUDE_TOOL_FILE_PATH\" ]] && git rev-parse --git-dir >/dev/null 2>&1; then git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Intelligent git commit creation with automatic message generation and validation. Creates meaningful commits based on file changes.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if git rev-parse --git-dir >/dev/null 2>&1 && [[ -n \"$CLAUDE_TOOL_FILE_PATH\" ]]; then git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null; CHANGED_LINES=$(git diff --cached --numstat \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null | awk '{print $1+$2}'); if [[ $CHANGED_LINES -gt 0 ]]; then FILENAME=$(basename \"$CLAUDE_TOOL_FILE_PATH\"); if [[ $CHANGED_LINES -lt 10 ]]; then SIZE=\"minor\"; elif [[ $CHANGED_LINES -lt 50 ]]; then SIZE=\"moderate\"; else SIZE=\"major\"; fi; MSG=\"Update $FILENAME: $SIZE changes ($CHANGED_LINES lines)\"; git commit -m \"$MSG\" \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi; fi"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if git rev-parse --git-dir >/dev/null 2>&1 && [[ -n \"$CLAUDE_TOOL_FILE_PATH\" ]]; then git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null; FILENAME=$(basename \"$CLAUDE_TOOL_FILE_PATH\"); git commit -m \"Add new file: $FILENAME\" \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Enforce conventional commit message format for all git commits. Validates commit messages follow the pattern: type(scope): description. Supported types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert. Ensures consistent commit history for changelog generation and semantic versioning.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/conventional-commits.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Only validate git commit commands
|
||||
if tool_name != "Bash" or "git commit" not in command:
|
||||
sys.exit(0)
|
||||
|
||||
# Extract commit message from -m flag
|
||||
# Handle both -m "message" and -m 'message' formats
|
||||
match = re.search(r'git commit.*?-m\s+["\']([^"\']+)["\']', command)
|
||||
if not match:
|
||||
# Also try heredoc format: -m "$(cat <<'EOF' ... EOF)"
|
||||
heredoc_match = re.search(r'git commit.*?-m\s+"?\$\(cat\s+<<["\']?EOF["\']?\s*\n(.+?)\nEOF', command, re.DOTALL)
|
||||
if heredoc_match:
|
||||
commit_msg = heredoc_match.group(1).strip()
|
||||
else:
|
||||
sys.exit(0) # Can't extract message, allow it
|
||||
else:
|
||||
commit_msg = match.group(1)
|
||||
|
||||
# Check if message follows Conventional Commits format
|
||||
# Format: type(scope)?: description
|
||||
# Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert
|
||||
conventional_pattern = r'^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?:\s.+'
|
||||
|
||||
if not re.match(conventional_pattern, commit_msg):
|
||||
reason = f"""❌ Invalid commit message format
|
||||
|
||||
Your message: {commit_msg}
|
||||
|
||||
Commit messages must follow Conventional Commits:
|
||||
type(scope): description
|
||||
|
||||
Types:
|
||||
feat: New feature
|
||||
fix: Bug fix
|
||||
docs: Documentation changes
|
||||
style: Code style changes (formatting)
|
||||
refactor: Code refactoring
|
||||
perf: Performance improvements
|
||||
test: Adding or updating tests
|
||||
chore: Maintenance tasks
|
||||
ci: CI/CD changes
|
||||
build: Build system changes
|
||||
revert: Revert previous commit
|
||||
|
||||
Examples:
|
||||
✅ feat: add user authentication
|
||||
✅ feat(auth): implement JWT tokens
|
||||
✅ fix: resolve memory leak in parser
|
||||
✅ fix(api): handle null responses
|
||||
✅ docs: update API documentation
|
||||
|
||||
Invalid:
|
||||
❌ Added new feature (no type)
|
||||
❌ feat:add feature (missing space after colon)
|
||||
❌ feature: add login (wrong type, use 'feat')
|
||||
|
||||
💡 Tip: Start your message with one of the types above followed by a colon and space."""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow the command
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Prevent direct pushes to protected branches (main, develop). Blocks git push commands targeting main or develop branches to enforce Git Flow workflow. Requires using feature/release/hotfix branches and pull requests instead of direct commits to protected branches.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prevent-direct-push.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Only validate git push commands
|
||||
if tool_name != "Bash" or "git push" not in command:
|
||||
sys.exit(0)
|
||||
|
||||
# Get current branch
|
||||
try:
|
||||
current_branch = subprocess.check_output(
|
||||
["git", "branch", "--show-current"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True
|
||||
).strip()
|
||||
except:
|
||||
current_branch = ""
|
||||
|
||||
# Check if pushing to main or develop
|
||||
push_cmd = command
|
||||
is_force_push = "--force" in push_cmd or "-f" in push_cmd
|
||||
|
||||
# Check if command or current branch targets protected branches
|
||||
targets_protected = (
|
||||
"origin main" in push_cmd or
|
||||
"origin develop" in push_cmd or
|
||||
current_branch in ["main", "develop"]
|
||||
)
|
||||
|
||||
# Block direct push to main/develop (unless force push which is already dangerous)
|
||||
if targets_protected and not is_force_push:
|
||||
if current_branch in ["main", "develop"] or "origin main" in push_cmd or "origin develop" in push_cmd:
|
||||
reason = f"""❌ Direct push to main/develop is not allowed!
|
||||
|
||||
Protected branches:
|
||||
- main (production)
|
||||
- develop (integration)
|
||||
|
||||
Git Flow workflow:
|
||||
1. Create a feature branch:
|
||||
/feature <name>
|
||||
|
||||
2. Make your changes and commit
|
||||
|
||||
3. Push feature branch:
|
||||
git push origin feature/<name>
|
||||
|
||||
4. Create pull request:
|
||||
gh pr create
|
||||
|
||||
5. After approval, merge with:
|
||||
/finish
|
||||
|
||||
For releases:
|
||||
/release <version> → PR → /finish
|
||||
|
||||
For hotfixes:
|
||||
/hotfix <name> → PR → /finish
|
||||
|
||||
Current branch: {current_branch}
|
||||
|
||||
💡 Use feature/release/hotfix branches instead of pushing directly to main/develop."""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow the command
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Validate Git Flow branch naming conventions before checkout. Ensures branches follow the pattern: feature/*, release/v*.*.*, hotfix/*. Prevents creation of branches that don't follow Git Flow standards.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate-branch-name.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Only validate git checkout -b commands
|
||||
if tool_name != "Bash" or "git checkout -b" not in command:
|
||||
sys.exit(0)
|
||||
|
||||
# Extract branch name
|
||||
match = re.search(r'git checkout -b\s+([^\s]+)', command)
|
||||
if not match:
|
||||
sys.exit(0)
|
||||
|
||||
branch_name = match.group(1)
|
||||
|
||||
# Allow main and develop branches
|
||||
if branch_name in ["main", "develop"]:
|
||||
sys.exit(0)
|
||||
|
||||
# Validate Git Flow naming convention
|
||||
if not re.match(r'^(feature|release|hotfix)/', branch_name):
|
||||
reason = f"""❌ Invalid Git Flow branch name: {branch_name}
|
||||
|
||||
Git Flow branches must follow these patterns:
|
||||
• feature/<descriptive-name>
|
||||
• release/v<MAJOR>.<MINOR>.<PATCH>
|
||||
• hotfix/<descriptive-name>
|
||||
|
||||
Examples:
|
||||
✅ feature/user-authentication
|
||||
✅ release/v1.2.0
|
||||
✅ hotfix/critical-security-fix
|
||||
|
||||
Invalid:
|
||||
❌ {branch_name} (missing Git Flow prefix)
|
||||
❌ feat/something (use 'feature/' not 'feat/')
|
||||
❌ fix/bug (use 'hotfix/' not 'fix/')
|
||||
|
||||
💡 Use Git Flow commands instead:
|
||||
/feature <name> - Create feature branch
|
||||
/release <version> - Create release branch
|
||||
/hotfix <name> - Create hotfix branch"""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Validate release version format
|
||||
if branch_name.startswith("release/"):
|
||||
if not re.match(r'^release/v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$', branch_name):
|
||||
reason = f"""❌ Invalid release version: {branch_name}
|
||||
|
||||
Release branches must follow semantic versioning:
|
||||
release/vMAJOR.MINOR.PATCH[-prerelease]
|
||||
|
||||
Valid examples:
|
||||
✅ release/v1.0.0
|
||||
✅ release/v2.1.3
|
||||
✅ release/v1.0.0-beta.1
|
||||
|
||||
Invalid:
|
||||
❌ release/1.0.0 (missing 'v' prefix)
|
||||
❌ release/v1.0 (incomplete version)
|
||||
❌ {branch_name}
|
||||
|
||||
💡 Use: /release v1.2.0"""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason
|
||||
}
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
# Allow the command
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"description": "Real-time browser visualization of Claude Code's context window and subagent/tool execution as a git-graph timeline. Opens http://localhost:7878 on SessionStart with a vertical timeline (most recent on top), one column per agent (main + subagents branching from their Task tool call). Tool calls are color-coded by type (Read=green, Edit/Write=orange, Bash=red, Grep/Glob=cyan, Task=purple, Web=yellow, MCP=gray). Right sidebar shows context-window usage (tokens/200K) with cache_read/cache_creation/input/output breakdown plus per-subagent mini-context. Reads ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl directly — no data replication, no network calls. Pure stdlib Python, zero pip dependencies. Persistent daemon HTTP server on port 7878 (auto-fallback 7879-7888 if busy; override with CONTEXT_TIMELINE_PORT env var). Watchdog auto-shutdown after 1h of inactivity. Disable browser auto-open with CONTEXT_TIMELINE_NO_BROWSER=1. Manual shutdown: python3 .claude/hooks/context-timeline.py --shutdown",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-timeline.py\" --server-start"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-timeline.py\" --event PreToolUse"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-timeline.py\" --event PostToolUse"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-timeline.py\" --event Stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Sends a native desktop notification when Claude Code finishes responding. Uses the Stop hook event so you get a single notification per response instead of one per tool call (which is very noisy with PostToolUse). Supports macOS (osascript) and Linux (notify-send). Useful when you switch to another window while Claude works — you'll get a notification when it's ready for your input.",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if command -v osascript >/dev/null 2>&1; then osascript -e 'display notification \"Response complete\" with title \"Claude Code\"'; elif command -v notify-send >/dev/null 2>&1; then notify-send 'Claude Code' 'Response complete'; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"description": "Automatically send Claude Code conversation traces to LangSmith for monitoring and analysis. Prerequisites: jq (brew install jq on macOS or sudo apt-get install jq on Linux), curl and uuidgen (usually pre-installed), LangSmith account and API key. Configuration: Install langsmith-tracing setting (npx claude-code-templates@latest --setting telemetry/langsmith-tracing) or manually add to .claude/settings.local.json the following environment variables: TRACE_TO_LANGSMITH=true, CC_LANGSMITH_API_KEY=lsv2_pt_..., CC_LANGSMITH_PROJECT=project-name, CC_LANGSMITH_DEBUG=true (optional). How it works: Runs in background on Stop event after each Claude response, reads conversation transcript, converts to LangSmith format, sends to LangSmith API, groups by thread_id for session continuity. Debugging: Check logs at ~/.claude/state/hook.log. Privacy note: System prompts not included in traces.",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/langsmith-tracing.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+957
@@ -0,0 +1,957 @@
|
||||
#!/bin/bash
|
||||
###
|
||||
# Claude Code Stop Hook - LangSmith Tracing Integration
|
||||
# Sends Claude Code traces to LangSmith after each response.
|
||||
###
|
||||
|
||||
set -e
|
||||
|
||||
# Config (needed early for logging)
|
||||
LOG_FILE="$HOME/.claude/state/hook.log"
|
||||
DEBUG="$(echo "$CC_LANGSMITH_DEBUG" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
# Logging functions
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') [$level] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
debug() {
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
log "DEBUG" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Immediate debug logging
|
||||
debug "Hook started, TRACE_TO_LANGSMITH=$TRACE_TO_LANGSMITH"
|
||||
|
||||
# Exit early if tracing disabled
|
||||
if [ "$(echo "$TRACE_TO_LANGSMITH" | tr '[:upper:]' '[:lower:]')" != "true" ]; then
|
||||
debug "Tracing disabled, exiting early"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Required commands
|
||||
for cmd in jq curl uuidgen; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
echo "Error: $cmd is required but not installed" >&2
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Config (continued)
|
||||
API_KEY="${CC_LANGSMITH_API_KEY:-$LANGSMITH_API_KEY}"
|
||||
PROJECT="${CC_LANGSMITH_PROJECT:-claude-code}"
|
||||
API_BASE="https://api.smith.langchain.com"
|
||||
STATE_FILE="${STATE_FILE:-$HOME/.claude/state/langsmith_state.json}"
|
||||
|
||||
# Global variables
|
||||
CURRENT_TURN_ID="" # Track current turn run for cleanup on exit
|
||||
|
||||
# Ensure state directory exists
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
|
||||
# Validate API key
|
||||
if [ -z "$API_KEY" ]; then
|
||||
log "ERROR" "CC_LANGSMITH_API_KEY not set"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get microseconds portably (macOS doesn't support date +%N)
|
||||
get_microseconds() {
|
||||
if command -v gdate &> /dev/null; then
|
||||
# Use GNU date if available (brew install coreutils)
|
||||
gdate +%6N
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS fallback: use Python for microseconds
|
||||
python3 -c "import time; print(str(int(time.time() * 1000000) % 1000000).zfill(6))"
|
||||
else
|
||||
# Linux/GNU date
|
||||
date +%6N
|
||||
fi
|
||||
}
|
||||
|
||||
# Get file size portably (macOS and Linux have different stat syntax)
|
||||
get_file_size() {
|
||||
local file="$1"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
stat -f%z "$file"
|
||||
else
|
||||
stat -c%s "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# API call helper
|
||||
api_call() {
|
||||
local method="$1"
|
||||
local endpoint="$2"
|
||||
local data="$3"
|
||||
|
||||
local response
|
||||
local http_code
|
||||
response=$(curl -s --max-time 60 -w "\n%{http_code}" -X "$method" \
|
||||
-H "x-api-key: $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_BASE$endpoint" 2>&1)
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
log "ERROR" "API call failed: $method $endpoint"
|
||||
log "ERROR" "HTTP $http_code: $response"
|
||||
log "ERROR" "Request data: ${data:0:500}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Cleanup function to complete pending turn run on exit
|
||||
cleanup_pending_turn() {
|
||||
if [ -n "$CURRENT_TURN_ID" ]; then
|
||||
debug "Cleanup: completing pending turn run $CURRENT_TURN_ID"
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local turn_update
|
||||
turn_update=$(jq -n \
|
||||
--arg time "$now" \
|
||||
'{
|
||||
outputs: {messages: []},
|
||||
end_time: $time,
|
||||
error: "Incomplete: script exited early"
|
||||
}')
|
||||
|
||||
# Try to complete the turn run (ignore errors since we're exiting anyway)
|
||||
api_call "PATCH" "/runs/$CURRENT_TURN_ID" "$turn_update" > /dev/null 2>&1 || true
|
||||
log "WARN" "Completed pending turn run $CURRENT_TURN_ID due to early exit"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set trap to cleanup on exit (EXIT covers normal exit, errors, and interrupts)
|
||||
trap cleanup_pending_turn EXIT
|
||||
|
||||
# Load state
|
||||
load_state() {
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo "{}"
|
||||
return
|
||||
fi
|
||||
cat "$STATE_FILE"
|
||||
}
|
||||
|
||||
# Save state
|
||||
save_state() {
|
||||
local state="$1"
|
||||
echo "$state" > "$STATE_FILE"
|
||||
}
|
||||
|
||||
# Get message content
|
||||
get_content() {
|
||||
local msg="$1"
|
||||
echo "$msg" | jq -c 'if type == "object" and has("message") then .message.content elif type == "object" then .content else null end'
|
||||
}
|
||||
|
||||
# Check if message is tool result
|
||||
is_tool_result() {
|
||||
local msg="$1"
|
||||
local content
|
||||
content=$(get_content "$msg")
|
||||
|
||||
if echo "$content" | jq -e 'if type == "array" then any(.[]; type == "object" and .type == "tool_result") else false end' > /dev/null 2>&1; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
|
||||
# Format content blocks for LangSmith
|
||||
format_content() {
|
||||
local msg="$1"
|
||||
local content
|
||||
content=$(get_content "$msg")
|
||||
|
||||
# Handle string content
|
||||
if echo "$content" | jq -e 'type == "string"' > /dev/null 2>&1; then
|
||||
echo "$content" | jq '[{"type": "text", "text": .}]'
|
||||
return
|
||||
fi
|
||||
|
||||
# Handle array content
|
||||
if echo "$content" | jq -e 'type == "array"' > /dev/null 2>&1; then
|
||||
echo "$content" | jq '[
|
||||
.[] |
|
||||
if type == "object" then
|
||||
if .type == "text" then
|
||||
{"type": "text", "text": .text}
|
||||
elif .type == "thinking" then
|
||||
{"type": "thinking", "thinking": .thinking}
|
||||
elif .type == "tool_use" then
|
||||
{"type": "tool_call", "name": .name, "args": .input, "id": .id}
|
||||
else
|
||||
.
|
||||
end
|
||||
elif type == "string" then
|
||||
{"type": "text", "text": .}
|
||||
else
|
||||
.
|
||||
end
|
||||
] | if length == 0 then [{"type": "text", "text": ""}] else . end'
|
||||
return
|
||||
fi
|
||||
|
||||
# Default
|
||||
echo '[{"type": "text", "text": ""}]'
|
||||
}
|
||||
|
||||
# Get tool uses from message
|
||||
get_tool_uses() {
|
||||
local msg="$1"
|
||||
local content
|
||||
content=$(get_content "$msg")
|
||||
|
||||
# Check if content is an array
|
||||
if ! echo "$content" | jq -e 'type == "array"' > /dev/null 2>&1; then
|
||||
echo "[]"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$content" | jq -c '[.[] | select(type == "object" and .type == "tool_use")]'
|
||||
}
|
||||
|
||||
# Get usage from assistant message parts (takes last for SSE cumulative counts)
|
||||
get_usage_from_parts() {
|
||||
local parts="$1"
|
||||
echo "$parts" | jq -c '
|
||||
[.[] | .message.usage // null | select(. != null)] | last // null
|
||||
'
|
||||
}
|
||||
|
||||
# Find tool result and timestamp
|
||||
# Returns JSON: {result: "...", timestamp: "..."}
|
||||
find_tool_result_with_timestamp() {
|
||||
local tool_id="$1"
|
||||
local tool_results="$2"
|
||||
|
||||
local result_data
|
||||
result_data=$(echo "$tool_results" | jq -c --arg id "$tool_id" '
|
||||
first(
|
||||
.[] |
|
||||
. as $msg |
|
||||
(if type == "object" and has("message") then .message.content elif type == "object" then .content else null end) as $content |
|
||||
if $content | type == "array" then
|
||||
$content[] |
|
||||
select(type == "object" and .type == "tool_result" and .tool_use_id == $id) |
|
||||
{
|
||||
result: (
|
||||
if .content | type == "array" then
|
||||
[.content[] | select(type == "object" and .type == "text") | .text] | join(" ")
|
||||
elif .content | type == "string" then
|
||||
.content
|
||||
else
|
||||
.content | tostring
|
||||
end
|
||||
),
|
||||
timestamp: $msg.timestamp
|
||||
}
|
||||
else
|
||||
empty
|
||||
end
|
||||
) // {result: "No result", timestamp: null}
|
||||
')
|
||||
|
||||
echo "$result_data"
|
||||
}
|
||||
|
||||
# Merge assistant message parts
|
||||
merge_assistant_parts() {
|
||||
local current_assistant_parts="$1"
|
||||
|
||||
# Extract usage from parts (last one for SSE cumulative)
|
||||
local usage
|
||||
usage=$(get_usage_from_parts "$current_assistant_parts")
|
||||
|
||||
echo "$current_assistant_parts" | jq -s \
|
||||
--argjson usage "$usage" \
|
||||
'
|
||||
.[0][0] as $base |
|
||||
(.[0] | map(if type == "object" and has("message") then .message.content elif type == "object" then .content else null end) | map(select(. != null))) as $contents |
|
||||
($contents | map(
|
||||
if type == "string" then [{"type":"text","text":.}]
|
||||
elif type == "array" then .
|
||||
else [.]
|
||||
end
|
||||
) | add // []) as $merged_content |
|
||||
($merged_content | reduce .[] as $item (
|
||||
{result: [], buffer: null};
|
||||
if $item.type == "text" then
|
||||
if .buffer then .buffer.text += $item.text
|
||||
else .buffer = $item
|
||||
end
|
||||
else
|
||||
(if .buffer then .result += [.buffer] else . end) |
|
||||
.buffer = null | .result += [$item]
|
||||
end
|
||||
) | if .buffer then .result + [.buffer] else .result end) as $final_content |
|
||||
$base |
|
||||
if type == "object" and has("message") then
|
||||
.message.content = $final_content |
|
||||
(if $usage != null then .message._usage = $usage else . end)
|
||||
elif type == "object" then
|
||||
.content = $final_content |
|
||||
(if $usage != null then ._usage = $usage else . end)
|
||||
else
|
||||
.
|
||||
end
|
||||
'
|
||||
}
|
||||
|
||||
# Serialize run data for multipart upload
|
||||
# Writes parts to temp files and outputs curl -F arguments (one per line)
|
||||
serialize_for_multipart() {
|
||||
local operation="$1" # "post" or "patch"
|
||||
local run_json="$2" # Full run JSON
|
||||
local temp_dir="$3" # Temp directory for this batch
|
||||
|
||||
local run_id
|
||||
run_id=$(echo "$run_json" | jq -r '.id')
|
||||
|
||||
# Extract inputs/outputs from main data
|
||||
local inputs
|
||||
inputs=$(echo "$run_json" | jq -c '.inputs // empty')
|
||||
|
||||
local outputs
|
||||
outputs=$(echo "$run_json" | jq -c '.outputs // empty')
|
||||
|
||||
local main_data
|
||||
main_data=$(echo "$run_json" | jq -c 'del(.inputs, .outputs)')
|
||||
|
||||
# Part 1: Main run data with Content-Length header
|
||||
local main_file="$temp_dir/${operation}_${run_id}_main.json"
|
||||
echo "$main_data" > "$main_file"
|
||||
local main_size=$(get_file_size "$main_file")
|
||||
echo "-F"
|
||||
echo "${operation}.${run_id}=<${main_file};type=application/json;headers=Content-Length:${main_size}"
|
||||
|
||||
# Part 2: Inputs (if present) with Content-Length header
|
||||
if [ "$inputs" != "null" ] && [ -n "$inputs" ]; then
|
||||
local inputs_file="$temp_dir/${operation}_${run_id}_inputs.json"
|
||||
echo "$inputs" > "$inputs_file"
|
||||
local inputs_size=$(get_file_size "$inputs_file")
|
||||
echo "-F"
|
||||
echo "${operation}.${run_id}.inputs=<${inputs_file};type=application/json;headers=Content-Length:${inputs_size}"
|
||||
fi
|
||||
|
||||
# Part 3: Outputs (if present) with Content-Length header
|
||||
if [ "$outputs" != "null" ] && [ -n "$outputs" ]; then
|
||||
local outputs_file="$temp_dir/${operation}_${run_id}_outputs.json"
|
||||
echo "$outputs" > "$outputs_file"
|
||||
local outputs_size=$(get_file_size "$outputs_file")
|
||||
echo "-F"
|
||||
echo "${operation}.${run_id}.outputs=<${outputs_file};type=application/json;headers=Content-Length:${outputs_size}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Send batch of runs via multipart endpoint
|
||||
send_multipart_batch() {
|
||||
local operation="$1" # "post" or "patch"
|
||||
local batch_json="$2" # JSON array of runs
|
||||
|
||||
# Parse batch size
|
||||
local batch_size
|
||||
batch_size=$(echo "$batch_json" | jq 'length')
|
||||
|
||||
if [ "$batch_size" -eq 0 ]; then
|
||||
debug "No $operation runs to send"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create temp directory for this batch
|
||||
local temp_dir
|
||||
temp_dir=$(mktemp -d)
|
||||
|
||||
# Build multipart curl command
|
||||
local curl_args=()
|
||||
curl_args+=("-s" "--max-time" "60" "-w" "\n%{http_code}" "-X" "POST")
|
||||
curl_args+=("-H" "x-api-key: $API_KEY")
|
||||
|
||||
# Serialize each run and collect curl -F arguments
|
||||
while IFS= read -r run; do
|
||||
# Read arguments line by line (proper array handling, no word splitting)
|
||||
while IFS= read -r arg; do
|
||||
curl_args+=("$arg")
|
||||
done < <(serialize_for_multipart "$operation" "$run" "$temp_dir")
|
||||
done < <(echo "$batch_json" | jq -c '.[]')
|
||||
|
||||
curl_args+=("$API_BASE/runs/multipart")
|
||||
|
||||
# Execute curl
|
||||
local response
|
||||
local http_code
|
||||
|
||||
response=$(curl "${curl_args[@]}" 2>&1)
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response=$(echo "$response" | sed '$d')
|
||||
|
||||
# Cleanup temp directory
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
log "ERROR" "Batch $operation failed: HTTP $http_code"
|
||||
log "ERROR" "Response: $response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "INFO" "Batch $operation succeeded: $batch_size runs"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Create LangSmith trace
|
||||
create_trace() {
|
||||
local session_id="$1"
|
||||
local turn_num="$2"
|
||||
local user_msg="$3"
|
||||
local assistant_messages="$4" # JSON array of assistant messages
|
||||
local tool_results="$5"
|
||||
|
||||
# Initialize batch collectors for this trace
|
||||
local posts_batch="[]"
|
||||
local patches_batch="[]"
|
||||
|
||||
local turn_id
|
||||
turn_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
local user_content
|
||||
user_content=$(format_content "$user_msg")
|
||||
|
||||
local now
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Create dotted_order timestamp with microseconds (format: YYYYMMDDTHHMMSSffffffZ)
|
||||
local dotted_timestamp
|
||||
dotted_timestamp=$(date -u +"%Y%m%dT%H%M%S")
|
||||
local microseconds
|
||||
microseconds=$(get_microseconds)
|
||||
dotted_timestamp="${dotted_timestamp}${microseconds}Z"
|
||||
|
||||
# Create top-level turn run with dotted_order and trace_id
|
||||
# For top-level run: trace_id = run_id
|
||||
local turn_dotted_order="${dotted_timestamp}${turn_id}"
|
||||
local turn_data
|
||||
turn_data=$(jq -n \
|
||||
--arg id "$turn_id" \
|
||||
--arg trace_id "$turn_id" \
|
||||
--arg name "Claude Code" \
|
||||
--arg project "$PROJECT" \
|
||||
--arg session "$session_id" \
|
||||
--arg time "$now" \
|
||||
--argjson content "$user_content" \
|
||||
--arg turn "$turn_num" \
|
||||
--arg dotted_order "$turn_dotted_order" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
name: $name,
|
||||
run_type: "chain",
|
||||
inputs: {messages: [{role: "user", content: $content}]},
|
||||
start_time: $time,
|
||||
dotted_order: $dotted_order,
|
||||
session_name: $project,
|
||||
extra: {metadata: {thread_id: $session}},
|
||||
tags: ["claude-code", ("turn-" + $turn)]
|
||||
}')
|
||||
|
||||
posts_batch=$(echo "$posts_batch" | jq --argjson data "$turn_data" '. += [$data]')
|
||||
|
||||
# Track this turn for cleanup on early exit
|
||||
CURRENT_TURN_ID="$turn_id"
|
||||
|
||||
# Build final outputs array (accumulates all LLM responses)
|
||||
local all_outputs
|
||||
all_outputs=$(jq -n --argjson content "$user_content" '[{role: "user", content: $content}]')
|
||||
|
||||
# Process each assistant message (each represents one LLM call)
|
||||
local llm_num=0
|
||||
local last_llm_end="$now"
|
||||
while IFS= read -r assistant_msg; do
|
||||
llm_num=$((llm_num + 1))
|
||||
|
||||
# Extract timestamp from message for proper ordering
|
||||
local msg_timestamp
|
||||
msg_timestamp=$(echo "$assistant_msg" | jq -r '.timestamp // ""')
|
||||
|
||||
# Use message timestamp for LLM start time
|
||||
local llm_start
|
||||
if [ -n "$msg_timestamp" ]; then
|
||||
llm_start="$msg_timestamp"
|
||||
elif [ $llm_num -eq 1 ]; then
|
||||
llm_start="$now"
|
||||
else
|
||||
llm_start="$last_llm_end"
|
||||
fi
|
||||
|
||||
# Create assistant run
|
||||
local assistant_id
|
||||
assistant_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
local tool_uses
|
||||
tool_uses=$(get_tool_uses "$assistant_msg")
|
||||
|
||||
local assistant_content
|
||||
assistant_content=$(format_content "$assistant_msg")
|
||||
|
||||
# Extract model name from assistant message and strip date suffix
|
||||
# e.g., "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5"
|
||||
local model_name
|
||||
model_name=$(echo "$assistant_msg" | jq -r 'if type == "object" and has("message") then .message.model else empty end' | sed 's/-[0-9]\{8\}$//')
|
||||
|
||||
# Extract usage data from assistant message (preserved by merge_assistant_parts)
|
||||
local msg_usage
|
||||
msg_usage=$(echo "$assistant_msg" | jq 'if type == "object" and has("message") then .message._usage // null elif type == "object" then ._usage // null else null end')
|
||||
|
||||
# Build usage_metadata for LangSmith
|
||||
local usage_metadata
|
||||
if [ "$msg_usage" != "null" ] && [ -n "$msg_usage" ]; then
|
||||
usage_metadata=$(echo "$msg_usage" | jq '{
|
||||
input_tokens: ((.input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.cache_read_input_tokens // 0)),
|
||||
output_tokens: (.output_tokens // 0),
|
||||
input_token_details: {
|
||||
cache_read: (.cache_read_input_tokens // 0),
|
||||
cache_creation: (.cache_creation_input_tokens // 0)
|
||||
}
|
||||
}')
|
||||
else
|
||||
usage_metadata="null"
|
||||
fi
|
||||
|
||||
# Build inputs for this LLM call (includes accumulated context)
|
||||
local llm_inputs
|
||||
llm_inputs=$(jq -n --argjson outputs "$all_outputs" '{messages: $outputs}')
|
||||
|
||||
# Create dotted_order for assistant (child of turn)
|
||||
# Convert ISO timestamp to dotted_order format
|
||||
# From: 2025-12-16T17:44:04.397Z
|
||||
# To: 20251216T174404397000Z (milliseconds padded to microseconds)
|
||||
local assistant_timestamp
|
||||
if [ -n "$msg_timestamp" ]; then
|
||||
# Extract and convert timestamp from message
|
||||
assistant_timestamp=$(echo "$msg_timestamp" | sed 's/[-:]//g; s/\.\([0-9]*\)Z$/\1000Z/; s/T\([0-9]*\)\([0-9]\{3\}\)000Z$/T\1\2000Z/')
|
||||
else
|
||||
# Fallback to current time if no timestamp
|
||||
assistant_timestamp=$(date -u +"%Y%m%dT%H%M%S")
|
||||
local assistant_microseconds
|
||||
assistant_microseconds=$(get_microseconds)
|
||||
assistant_timestamp="${assistant_timestamp}${assistant_microseconds}Z"
|
||||
fi
|
||||
local assistant_dotted_order="${turn_dotted_order}.${assistant_timestamp}${assistant_id}"
|
||||
|
||||
# Extract trace_id from parent dotted_order (UUID after the Z)
|
||||
# Format: 20231215T120000123456Zuuid -> uuid
|
||||
local trace_id
|
||||
trace_id="${turn_dotted_order#*Z}"
|
||||
|
||||
local assistant_data
|
||||
assistant_data=$(jq -n \
|
||||
--arg id "$assistant_id" \
|
||||
--arg trace_id "$trace_id" \
|
||||
--arg parent "$turn_id" \
|
||||
--arg name "Claude" \
|
||||
--arg project "$PROJECT" \
|
||||
--arg time "$llm_start" \
|
||||
--argjson inputs "$llm_inputs" \
|
||||
--arg dotted_order "$assistant_dotted_order" \
|
||||
--arg model "$model_name" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
parent_run_id: $parent,
|
||||
name: $name,
|
||||
run_type: "llm",
|
||||
inputs: $inputs,
|
||||
start_time: $time,
|
||||
dotted_order: $dotted_order,
|
||||
session_name: $project,
|
||||
extra: {metadata: {ls_provider: "anthropic", ls_model_name: $model}},
|
||||
tags: [$model]
|
||||
}')
|
||||
|
||||
posts_batch=$(echo "$posts_batch" | jq --argjson data "$assistant_data" '. += [$data]')
|
||||
|
||||
# Build outputs for this LLM call
|
||||
local llm_outputs
|
||||
llm_outputs=$(jq -n --argjson content "$assistant_content" '[{role: "assistant", content: $content}]')
|
||||
|
||||
# Track when this LLM iteration ends (after tools complete)
|
||||
local assistant_end
|
||||
|
||||
# Create tool runs as siblings of the assistant run
|
||||
if [ "$(echo "$tool_uses" | jq 'length')" -gt 0 ]; then
|
||||
# First tool starts after LLM completes
|
||||
# Use llm_start as LLM end time approximation (we don't have separate end timestamp)
|
||||
local tool_start
|
||||
tool_start="$llm_start"
|
||||
|
||||
# If there are multiple assistant parts, the last timestamp is closer to LLM end
|
||||
local llm_end_approx
|
||||
llm_end_approx=$(echo "$assistant_msg" | jq -r '.timestamp // ""')
|
||||
if [ -n "$llm_end_approx" ]; then
|
||||
tool_start="$llm_end_approx"
|
||||
fi
|
||||
|
||||
while IFS= read -r tool; do
|
||||
local tool_id
|
||||
tool_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
local tool_name
|
||||
tool_name=$(echo "$tool" | jq -r '.name // "tool"')
|
||||
|
||||
local tool_input
|
||||
tool_input=$(echo "$tool" | jq '.input // {}')
|
||||
|
||||
local tool_use_id
|
||||
tool_use_id=$(echo "$tool" | jq -r '.id // ""')
|
||||
|
||||
# Find tool result and extract timestamp from transcript
|
||||
local result_data
|
||||
result_data=$(find_tool_result_with_timestamp "$tool_use_id" "$tool_results")
|
||||
|
||||
local result
|
||||
result=$(echo "$result_data" | jq -r '.result')
|
||||
|
||||
local tool_result_timestamp
|
||||
tool_result_timestamp=$(echo "$result_data" | jq -r '.timestamp // ""')
|
||||
|
||||
# Create dotted_order for tool (child of turn)
|
||||
# Use the tool result timestamp from transcript for proper ordering
|
||||
local tool_timestamp
|
||||
if [ -n "$tool_result_timestamp" ]; then
|
||||
# Convert ISO timestamp to dotted_order format
|
||||
# From: 2025-12-16T17:44:04.397Z
|
||||
# To: 20251216T174404397000Z (milliseconds padded to microseconds)
|
||||
tool_timestamp=$(echo "$tool_result_timestamp" | sed 's/[-:]//g; s/\.\([0-9]*\)Z$/\1000Z/; s/T\([0-9]*\)\([0-9]\{3\}\)000Z$/T\1\2000Z/')
|
||||
else
|
||||
# Fallback to current time if no timestamp in transcript
|
||||
tool_timestamp=$(date -u +"%Y%m%dT%H%M%S")
|
||||
local tool_microseconds
|
||||
tool_microseconds=$(get_microseconds)
|
||||
tool_timestamp="${tool_timestamp}${tool_microseconds}Z"
|
||||
fi
|
||||
|
||||
local tool_dotted_order="${turn_dotted_order}.${tool_timestamp}${tool_id}"
|
||||
|
||||
# Use tool result timestamp for end time as well
|
||||
local tool_end
|
||||
if [ -n "$tool_result_timestamp" ]; then
|
||||
tool_end="$tool_result_timestamp"
|
||||
else
|
||||
tool_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
fi
|
||||
|
||||
# Tools are siblings of the assistant run (both children of turn run)
|
||||
local tool_data
|
||||
tool_data=$(jq -n \
|
||||
--arg id "$tool_id" \
|
||||
--arg trace_id "$trace_id" \
|
||||
--arg parent "$turn_id" \
|
||||
--arg name "$tool_name" \
|
||||
--arg project "$PROJECT" \
|
||||
--arg time "$tool_start" \
|
||||
--argjson input "$tool_input" \
|
||||
--arg dotted_order "$tool_dotted_order" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
parent_run_id: $parent,
|
||||
name: $name,
|
||||
run_type: "tool",
|
||||
inputs: {input: $input},
|
||||
start_time: $time,
|
||||
dotted_order: $dotted_order,
|
||||
session_name: $project,
|
||||
tags: ["tool"]
|
||||
}')
|
||||
|
||||
posts_batch=$(echo "$posts_batch" | jq --argjson data "$tool_data" '. += [$data]')
|
||||
|
||||
local tool_update
|
||||
tool_update=$(echo "$result" | jq -Rs \
|
||||
--arg time "$tool_end" \
|
||||
--arg id "$tool_id" \
|
||||
--arg trace_id "$trace_id" \
|
||||
--arg parent "$turn_id" \
|
||||
--arg dotted_order "$tool_dotted_order" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
parent_run_id: $parent,
|
||||
dotted_order: $dotted_order,
|
||||
outputs: {output: .},
|
||||
end_time: $time
|
||||
}')
|
||||
|
||||
patches_batch=$(echo "$patches_batch" | jq --argjson data "$tool_update" '. += [$data]')
|
||||
|
||||
# Next tool starts after this one ends
|
||||
tool_start="$tool_end"
|
||||
|
||||
done < <(echo "$tool_uses" | jq -c '.[]')
|
||||
|
||||
# Assistant completes after all tools finish
|
||||
assistant_end="$tool_start"
|
||||
else
|
||||
# No tools, assistant completes immediately
|
||||
assistant_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
fi
|
||||
|
||||
# Now complete the assistant run
|
||||
local assistant_update
|
||||
assistant_update=$(jq -n \
|
||||
--arg time "$assistant_end" \
|
||||
--arg id "$assistant_id" \
|
||||
--arg trace_id "$trace_id" \
|
||||
--arg parent "$turn_id" \
|
||||
--arg dotted_order "$assistant_dotted_order" \
|
||||
--argjson outputs "$llm_outputs" \
|
||||
--argjson usage_metadata "$usage_metadata" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
parent_run_id: $parent,
|
||||
dotted_order: $dotted_order,
|
||||
outputs: ({messages: $outputs} + (if $usage_metadata != null then {usage_metadata: $usage_metadata} else {} end)),
|
||||
end_time: $time
|
||||
}')
|
||||
|
||||
patches_batch=$(echo "$patches_batch" | jq --argjson data "$assistant_update" '. += [$data]')
|
||||
|
||||
# Save end time for next LLM start
|
||||
last_llm_end="$assistant_end"
|
||||
|
||||
# Add to overall outputs
|
||||
all_outputs=$(echo "$all_outputs" | jq --argjson new "$llm_outputs" '. += $new')
|
||||
|
||||
# Add tool results to accumulated context (for next LLM's inputs)
|
||||
if [ "$(echo "$tool_uses" | jq 'length')" -gt 0 ]; then
|
||||
while IFS= read -r tool; do
|
||||
local tool_use_id
|
||||
tool_use_id=$(echo "$tool" | jq -r '.id // ""')
|
||||
local result_data
|
||||
result_data=$(find_tool_result_with_timestamp "$tool_use_id" "$tool_results")
|
||||
local result
|
||||
result=$(echo "$result_data" | jq -r '.result')
|
||||
all_outputs=$(echo "$all_outputs" | jq \
|
||||
--arg id "$tool_use_id" \
|
||||
--arg result "$result" \
|
||||
'. += [{role: "tool", tool_call_id: $id, content: [{type: "text", text: $result}]}]')
|
||||
done < <(echo "$tool_uses" | jq -c '.[]')
|
||||
fi
|
||||
|
||||
done < <(echo "$assistant_messages" | jq -c '.[]')
|
||||
|
||||
# Update turn run with all outputs
|
||||
# Filter out user messages from final outputs
|
||||
local turn_outputs
|
||||
turn_outputs=$(echo "$all_outputs" | jq '[.[] | select(.role != "user")]')
|
||||
|
||||
# Use the last LLM's end time as the turn end time
|
||||
local turn_end="$last_llm_end"
|
||||
|
||||
local turn_update
|
||||
turn_update=$(jq -n \
|
||||
--arg time "$turn_end" \
|
||||
--arg id "$turn_id" \
|
||||
--arg trace_id "$turn_id" \
|
||||
--arg dotted_order "$turn_dotted_order" \
|
||||
--argjson outputs "$turn_outputs" \
|
||||
'{
|
||||
id: $id,
|
||||
trace_id: $trace_id,
|
||||
dotted_order: $dotted_order,
|
||||
outputs: {messages: $outputs},
|
||||
end_time: $time
|
||||
}')
|
||||
|
||||
patches_batch=$(echo "$patches_batch" | jq --argjson data "$turn_update" '. += [$data]')
|
||||
|
||||
# Send both batches
|
||||
send_multipart_batch "post" "$posts_batch" || true
|
||||
send_multipart_batch "patch" "$patches_batch" || true
|
||||
|
||||
# Clear the tracked turn since it's now complete
|
||||
CURRENT_TURN_ID=""
|
||||
|
||||
log "INFO" "Created turn $turn_num: $turn_id with $llm_num LLM call(s)"
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
# Track execution time
|
||||
local script_start
|
||||
script_start=$(date +%s)
|
||||
|
||||
# Read hook input
|
||||
local hook_input
|
||||
hook_input=$(cat)
|
||||
|
||||
# Check stop_hook_active flag
|
||||
if echo "$hook_input" | jq -e '.stop_hook_active == true' > /dev/null 2>&1; then
|
||||
debug "stop_hook_active=true, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract session info
|
||||
local session_id
|
||||
session_id=$(echo "$hook_input" | jq -r '.session_id // ""')
|
||||
|
||||
local transcript_path
|
||||
transcript_path=$(echo "$hook_input" | jq -r '.transcript_path // ""' | sed "s|^~|$HOME|")
|
||||
|
||||
if [ -z "$session_id" ] || [ ! -f "$transcript_path" ]; then
|
||||
log "WARN" "Invalid input: session=$session_id, transcript=$transcript_path"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "INFO" "Processing session $session_id"
|
||||
|
||||
# Load state
|
||||
local state
|
||||
state=$(load_state)
|
||||
|
||||
local last_line
|
||||
last_line=$(echo "$state" | jq -r --arg sid "$session_id" '.[$sid].last_line // -1')
|
||||
|
||||
local turn_count
|
||||
turn_count=$(echo "$state" | jq -r --arg sid "$session_id" '.[$sid].turn_count // 0')
|
||||
|
||||
# Parse new messages
|
||||
local new_messages
|
||||
new_messages=$(awk -v start="$last_line" 'NR > start + 1 && NF' "$transcript_path")
|
||||
|
||||
if [ -z "$new_messages" ]; then
|
||||
debug "No new messages"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local msg_count
|
||||
msg_count=$(echo "$new_messages" | wc -l)
|
||||
log "INFO" "Found $msg_count new messages"
|
||||
|
||||
# Group into turns
|
||||
local current_user=""
|
||||
local current_assistants="[]" # Array of assistant messages
|
||||
local current_msg_id="" # Current assistant message ID
|
||||
local current_assistant_parts="[]" # Parts of current assistant message
|
||||
local current_tool_results="[]"
|
||||
local turns=0
|
||||
local new_last_line=$last_line
|
||||
|
||||
while IFS= read -r line; do
|
||||
new_last_line=$((new_last_line + 1))
|
||||
|
||||
if [ -z "$line" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local role
|
||||
role=$(echo "$line" | jq -r 'if type == "object" and has("message") then .message.role elif type == "object" then .role else "unknown" end')
|
||||
|
||||
if [ "$role" = "user" ]; then
|
||||
if [ "$(is_tool_result "$line")" = "true" ]; then
|
||||
# Add to tool results
|
||||
current_tool_results=$(echo "$current_tool_results" | jq --argjson msg "$line" '. += [$msg]')
|
||||
else
|
||||
# New turn - finalize any pending assistant message
|
||||
if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
|
||||
# Merge parts and add to assistants array
|
||||
local merged
|
||||
merged=$(merge_assistant_parts "$current_assistant_parts")
|
||||
current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
|
||||
current_assistant_parts="[]"
|
||||
current_msg_id=""
|
||||
fi
|
||||
|
||||
# Create trace for previous turn
|
||||
if [ -n "$current_user" ] && [ "$(echo "$current_assistants" | jq 'length')" -gt 0 ]; then
|
||||
turns=$((turns + 1))
|
||||
local turn_num=$((turn_count + turns))
|
||||
create_trace "$session_id" "$turn_num" "$current_user" "$current_assistants" "$current_tool_results" || true
|
||||
fi
|
||||
|
||||
# Start new turn
|
||||
current_user="$line"
|
||||
current_assistants="[]"
|
||||
current_assistant_parts="[]"
|
||||
current_msg_id=""
|
||||
current_tool_results="[]"
|
||||
fi
|
||||
elif [ "$role" = "assistant" ]; then
|
||||
# Get message ID
|
||||
local msg_id
|
||||
msg_id=$(echo "$line" | jq -r 'if type == "object" and has("message") then .message.id else "" end')
|
||||
|
||||
if [ -z "$msg_id" ]; then
|
||||
# No message ID, treat as continuation of current message
|
||||
current_assistant_parts=$(echo "$current_assistant_parts" | jq --argjson msg "$line" '. += [$msg]')
|
||||
elif [ "$msg_id" = "$current_msg_id" ]; then
|
||||
# Same message ID, add to current parts
|
||||
current_assistant_parts=$(echo "$current_assistant_parts" | jq --argjson msg "$line" '. += [$msg]')
|
||||
else
|
||||
# New message ID - finalize previous message if any
|
||||
if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
|
||||
# Merge parts and add to assistants array
|
||||
local merged
|
||||
merged=$(merge_assistant_parts "$current_assistant_parts")
|
||||
current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
|
||||
fi
|
||||
|
||||
# Start new assistant message
|
||||
current_msg_id="$msg_id"
|
||||
current_assistant_parts=$(jq -n --argjson msg "$line" '[$msg]')
|
||||
fi
|
||||
fi
|
||||
done <<< "$new_messages"
|
||||
|
||||
# Process final turn - finalize any pending assistant message
|
||||
if [ -n "$current_msg_id" ] && [ "$(echo "$current_assistant_parts" | jq 'length')" -gt 0 ]; then
|
||||
local merged
|
||||
merged=$(merge_assistant_parts "$current_assistant_parts")
|
||||
current_assistants=$(echo "$current_assistants" | jq --argjson msg "$merged" '. += [$msg]')
|
||||
fi
|
||||
|
||||
if [ -n "$current_user" ] && [ "$(echo "$current_assistants" | jq 'length')" -gt 0 ]; then
|
||||
turns=$((turns + 1))
|
||||
local turn_num=$((turn_count + turns))
|
||||
create_trace "$session_id" "$turn_num" "$current_user" "$current_assistants" "$current_tool_results" || true
|
||||
fi
|
||||
|
||||
# Update state
|
||||
local updated
|
||||
updated=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
state=$(echo "$state" | jq \
|
||||
--arg sid "$session_id" \
|
||||
--arg line "$new_last_line" \
|
||||
--arg count "$((turn_count + turns))" \
|
||||
--arg time "$updated" \
|
||||
'.[$sid] = {last_line: ($line | tonumber), turn_count: ($count | tonumber), updated: $time}')
|
||||
|
||||
save_state "$state"
|
||||
|
||||
# Log execution time
|
||||
local script_end
|
||||
script_end=$(date +%s)
|
||||
local duration=$((script_end - script_start))
|
||||
|
||||
log "INFO" "Processed $turns turns in ${duration}s"
|
||||
if [ "$duration" -gt 180 ]; then
|
||||
log "WARN" "Hook took ${duration}s (>3min), consider optimizing"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main
|
||||
main
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "Monitor bundle size and Core Web Vitals metrics during development, blocking deployments that exceed performance budgets with detailed reports. Automatically analyzes Next.js build output, checks bundle sizes against predefined budgets, and provides optimization recommendations. Hook triggers on PostToolUse for build-related operations and file changes that could affect performance.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); COMMAND=$(echo \"$input\" | jq -r \".tool_input.command // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [ \"$SUCCESS\" = \"true\" ] && [[ \"$COMMAND\" =~ (npm\\ run\\ build|next\\ build|vercel\\ build|yarn\\ build) ]]; then echo \"📊 Performance Budget Guard: Analyzing build output...\"; if [ -d \".next\" ]; then BUNDLE_SIZE=$(find .next/static/chunks -name \"*.js\" -exec stat -f%z {} + 2>/dev/null | awk \"{total += \\$1} END {print total}\"); BUNDLE_SIZE_KB=$((BUNDLE_SIZE / 1024)); BUDGET_LIMIT=350; echo \"📦 Total bundle size: ${BUNDLE_SIZE_KB}KB\"; if [ $BUNDLE_SIZE_KB -gt $BUDGET_LIMIT ]; then echo \"🚨 PERFORMANCE BUDGET EXCEEDED!\"; echo \"Current bundle size: ${BUNDLE_SIZE_KB}KB\"; echo \"Budget limit: ${BUDGET_LIMIT}KB\"; echo \"Overage: $((BUNDLE_SIZE_KB - BUDGET_LIMIT))KB\"; echo \"\" >&2; echo \"⚠️ Performance budget exceeded by $((BUNDLE_SIZE_KB - BUDGET_LIMIT))KB!\" >&2; echo \"\" >&2; echo \"📋 Bundle Analysis:\" >&2; find .next/static/chunks -name \"*.js\" -exec ls -lah {} + 2>/dev/null | sort -k5 -hr | head -5 >&2; echo \"\" >&2; echo \"💡 Optimization recommendations:\" >&2; echo \"• Use dynamic imports for large components\" >&2; echo \"• Implement code splitting with next/dynamic\" >&2; echo \"• Check for duplicate dependencies\" >&2; echo \"• Optimize third-party libraries\" >&2; echo \"• Run: npm run analyze for detailed bundle analysis\" >&2; exit 2; else echo \"✅ Bundle size within budget: ${BUNDLE_SIZE_KB}KB / ${BUDGET_LIMIT}KB\"; REMAINING=$((BUDGET_LIMIT - BUNDLE_SIZE_KB)); echo \"🎯 Remaining budget: ${REMAINING}KB\"; if [ $REMAINING -lt 50 ]; then echo \"⚠️ Warning: Less than 50KB remaining in performance budget\"; fi; fi; else echo \"❌ No .next build directory found. Run build first.\"; fi; else echo \"ℹ️ Performance check skipped (not a build command or failed build)\"; fi'",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'input=$(cat); FILE_PATH=$(echo \"$input\" | jq -r \".tool_input.file_path // empty\"); SUCCESS=$(echo \"$input\" | jq -r \".tool_response.success // false\"); if [ \"$SUCCESS\" = \"true\" ] && [[ \"$FILE_PATH\" =~ \\.(js|jsx|ts|tsx)$ ]] && [[ ! \"$FILE_PATH\" =~ node_modules ]]; then echo \"🔍 Performance Guard: Checking code changes in $FILE_PATH...\"; if [ -f \"$FILE_PATH\" ]; then FILE_SIZE=$(stat -f%z \"$FILE_PATH\" 2>/dev/null || stat -c%s \"$FILE_PATH\" 2>/dev/null); FILE_SIZE_KB=$((FILE_SIZE / 1024)); if [ $FILE_SIZE_KB -gt 100 ]; then echo \"⚠️ Large file detected: ${FILE_SIZE_KB}KB\"; echo \"💡 Consider splitting large components or lazy loading\"; fi; IMPORTS_COUNT=$(grep -c \"^import\" \"$FILE_PATH\" 2>/dev/null || echo \"0\"); if [ $IMPORTS_COUNT -gt 15 ]; then echo \"📦 Many imports detected: $IMPORTS_COUNT imports\"; echo \"💡 Consider consolidating imports or tree-shaking unused code\"; fi; if grep -q \"import.*\\*.*from\" \"$FILE_PATH\" 2>/dev/null; then echo \"🚨 Wildcard import detected in $FILE_PATH\"; echo \"💡 Use specific imports instead of wildcard imports for better tree-shaking\"; fi; if grep -q \"moment\" \"$FILE_PATH\" 2>/dev/null; then echo \"📅 Moment.js usage detected\"; echo \"💡 Consider using date-fns or native Date for smaller bundle size\"; fi; if grep -q \"lodash\" \"$FILE_PATH\" 2>/dev/null && ! grep -q \"lodash/\" \"$FILE_PATH\" 2>/dev/null; then echo \"🔧 Full Lodash import detected\"; echo \"💡 Use specific Lodash functions: import debounce from \\\"lodash/debounce\\\"\"; fi; echo \"✅ Performance check completed for $FILE_PATH\"; else echo \"❌ File $FILE_PATH not found\"; fi; else echo \"ℹ️ Performance check skipped (not a JavaScript/TypeScript file or failed operation)\"; fi'",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "Monitor system performance during Claude Code operations. Tracks CPU, memory usage, and execution time for performance optimization.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo \"$(date +%s.%N),$(ps -o %cpu= -p $$),$(ps -o rss= -p $$),$CLAUDE_TOOL_NAME,start\" >> ~/.claude/performance.csv"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo \"$(date +%s.%N),$(ps -o %cpu= -p $$),$(ps -o rss= -p $$),$CLAUDE_TOOL_NAME,end\" >> ~/.claude/performance.csv; if [[ $(wc -l < ~/.claude/performance.csv) -gt 1000 ]]; then tail -n 500 ~/.claude/performance.csv > ~/.claude/performance.csv.tmp && mv ~/.claude/performance.csv.tmp ~/.claude/performance.csv; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically format JavaScript/TypeScript files after any Edit operation using prettier. This hook runs 'npx prettier --write' on any .js, .ts, .jsx, or .tsx file that Claude modifies, ensuring consistent code formatting. Uses npx so prettier doesn't need to be globally installed. Includes error suppression so it won't fail if prettier is not available.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx prettier --write \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically format Python files after any Edit operation using black formatter. This hook runs 'black' on any .py file that Claude modifies, ensuring consistent Python code formatting. Requires black to be installed ('pip install black'). The command includes error suppression (2>/dev/null || true) so it won't fail if black is not installed.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then black \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Automatically stage changes in git after file modifications for easier commit workflow. This hook runs 'git add' on any file that Claude edits or writes, automatically staging changes for the next commit. Includes error suppression so it won't fail in non-git repositories. Helps streamline the development workflow by preparing files for commit.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git add \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically run quick tests after code modifications to ensure nothing breaks. This hook executes 'npm run test:quick' silently after any Edit operation and provides feedback on test status. Helps catch breaking changes immediately during development. Only runs if package.json exists and the test:quick script is available.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -f package.json ]] && npm run test:quick --silent >/dev/null 2>&1; then echo '✅ Tests passed'; else echo '⚠️ Tests may need attention'; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Create automatic backup of files before any Edit operation for safety. This hook creates a timestamped backup copy (filename.backup.timestamp) of any existing file before Claude modifies it. Provides a safety net to recover previous versions if needed. Only backs up existing files, includes error suppression to handle edge cases gracefully.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ -f \"$CLAUDE_TOOL_FILE_PATH\" ]]; then cp \"$CLAUDE_TOOL_FILE_PATH\" \"$CLAUDE_TOOL_FILE_PATH.backup.$(date +%s)\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Warns about console.log statements when editing files on production branches (main/master). Helps prevent debug code from reaching production.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if git rev-parse --git-dir >/dev/null 2>&1 && [[ -n \"$CLAUDE_TOOL_FILE_PATH\" ]]; then BRANCH=$(git branch --show-current 2>/dev/null); if [[ \"$BRANCH\" =~ ^(main|master|production|prod)$ ]]; then EXT=\"${CLAUDE_TOOL_FILE_PATH##*.}\"; if [[ \"$EXT\" =~ ^(js|ts|jsx|tsx|mjs|cjs)$ ]]; then LOGS=$(grep -n 'console\\.' \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null); if [[ -n \"$LOGS\" ]]; then echo \"⚠️ WARNING: console statements found on '$BRANCH' branch:\"; echo \"$LOGS\" | head -5; COUNT=$(echo \"$LOGS\" | wc -l); if [[ $COUNT -gt 5 ]]; then echo \"... and $((COUNT-5)) more\"; fi; echo \"Consider removing debug statements before merging.\"; fi; fi; fi; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Show notification before any Bash command execution for security awareness. This hook displays a simple echo message '🔔 About to run bash command...' before Claude executes any bash command, giving you visibility into when system commands are about to run. Useful for monitoring and auditing command execution.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo '🔔 About to run bash command...'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Automatically adds current year to WebSearch queries when no year is specified. This hook intercepts WebSearch tool usage and appends the current year to queries that don't already contain a year, ensuring search results are current and relevant.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "WebSearch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -c \"import json, sys, re; from datetime import datetime; input_data = json.load(sys.stdin); tool_input = input_data.get('tool_input', {}); query = tool_input.get('query', ''); current_year = str(datetime.now().year); has_year = re.search(r'\\\\b20\\\\d{2}\\\\b', query); has_temporal = any(word in query.lower() for word in ['latest', 'recent', 'current', 'new', 'now', 'today']); should_add_year = not has_year and not has_temporal; modified_query = f'{query} {current_year}' if should_add_year else query; output = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'modifiedToolInput': {'query': modified_query}}}; print(json.dumps(output)); sys.exit(0)\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"description": "Planning gate that warns when editing production code without an approved specification. Checks for recent .spec.md files in the project directory (last 14 days). If no spec is found, shows a warning suggesting to create one first. Non-blocking (exit 0) — acts as a reminder, not a hard gate. Supports 16 programming languages. Part of the Spec-Driven Development (SDD) methodology where every implementation should be backed by an approved specification.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "plan-gate.sh",
|
||||
"destination": ".claude/hooks/plan-gate.sh",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-gate.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-gate.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/plan-gate.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# plan-gate.sh — Warning if implementing code without an approved spec
|
||||
# Source: pm-workspace (https://github.com/gonzalezpazmonica/pm-workspace)
|
||||
# License: MIT
|
||||
#
|
||||
# PreToolUse hook (Edit|MultiEdit|Write) that warns if source code is being
|
||||
# edited without a recent specification file (.spec.md) in the project.
|
||||
# Non-blocking: shows warning but allows the edit to proceed.
|
||||
# NOTE: No 'set -euo pipefail' — this hook must NEVER block unintentionally.
|
||||
|
||||
# Only check source code files
|
||||
FILE="${CLAUDE_TOOL_INPUT_FILE:-}"
|
||||
[ -z "$FILE" ] && exit 0
|
||||
|
||||
case "$FILE" in
|
||||
*.cs|*.ts|*.tsx|*.js|*.jsx|*.py|*.go|*.rs|*.php|*.rb|*.java|*.kt|*.swift|*.dart|*.vb|*.cbl) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Look for active spec in the project
|
||||
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
||||
|
||||
# Check for recent specs (last sprint = last 14 days)
|
||||
# Only test existence — no need to enumerate all matches (avoids latency in large repos)
|
||||
HAS_SPEC=0
|
||||
if find "$PROJECT_DIR" -name "*.spec.md" -mtime -14 -type f -print -quit 2>/dev/null | grep -q .; then
|
||||
HAS_SPEC=1
|
||||
fi
|
||||
|
||||
if [ "$HAS_SPEC" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "Plan Gate: No recent approved spec found (.spec.md modified in last 14 days)."
|
||||
echo " Consider creating a specification before implementing."
|
||||
echo " (Warning only — does not block the edit)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "Scope guard that detects files modified outside the declared scope of a specification. When a .spec.md file contains a 'Files to Create/Modify' section, this hook compares git-modified files against the declared list. Files outside scope trigger a warning (non-blocking). Automatically excludes test files, config files, infrastructure files, and documentation. Essential for Spec-Driven Development to prevent scope creep during implementation.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "scope-guard.sh",
|
||||
"destination": ".claude/hooks/scope-guard.sh",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/scope-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# scope-guard.sh — Detect files modified outside the scope of an active spec
|
||||
# Source: pm-workspace (https://github.com/gonzalezpazmonica/pm-workspace)
|
||||
# License: MIT
|
||||
#
|
||||
# Compares git-modified files against files declared in the active .spec.md.
|
||||
# Warns about out-of-scope modifications but does NOT block (exit 0).
|
||||
# Essential for preventing scope creep during Spec-Driven Development.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||
|
||||
# Get modified files (tracked unstaged + staged), handle safely
|
||||
MODIFIED=$({ git diff --name-only 2>/dev/null; git diff --cached --name-only 2>/dev/null; } | sort -u)
|
||||
|
||||
if [ -z "$MODIFIED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find active spec: most recently modified .spec.md (within last 60 minutes)
|
||||
# Sort by modification time (-t) and take the newest one
|
||||
SPEC_FILE=$(find "$PROJECT_ROOT" -name "*.spec.md" -mmin -60 -type f -printf '%T@ %p\n' 2>/dev/null \
|
||||
| sort -rn | head -1 | cut -d' ' -f2-)
|
||||
|
||||
# Fallback for systems without -printf (macOS)
|
||||
if [ -z "$SPEC_FILE" ]; then
|
||||
SPEC_FILE=$(find "$PROJECT_ROOT" -name "*.spec.md" -mmin -60 -type f -print0 2>/dev/null \
|
||||
| xargs -0 ls -t 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
# If no active spec, cannot verify scope
|
||||
if [ -z "$SPEC_FILE" ] || [ ! -f "$SPEC_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract declared files from spec using broader regex
|
||||
# Matches paths like: src/Application/Orders/Handler.cs, tests/unit/test_foo.py
|
||||
DECLARED=$(sed -n '/[Ff]icheros\|[Ff]iles to [Cc]reate\|[Ff]iles to [Mm]odify/,/^## /p' "$SPEC_FILE" \
|
||||
| grep -oE '[a-zA-Z0-9_./@\-]+\.[a-zA-Z0-9]{1,8}' \
|
||||
| sort -u)
|
||||
|
||||
if [ -z "$DECLARED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compare: find modified files NOT in the declared list
|
||||
# Use while-read to handle filenames with spaces safely
|
||||
OUT_OF_SCOPE=""
|
||||
while IFS= read -r FILE; do
|
||||
[ -z "$FILE" ] && continue
|
||||
BASENAME=$(basename "$FILE")
|
||||
MATCH=0
|
||||
|
||||
while IFS= read -r DECL; do
|
||||
[ -z "$DECL" ] && continue
|
||||
# Exact path match
|
||||
if [ "$FILE" = "$DECL" ]; then
|
||||
MATCH=1
|
||||
break
|
||||
fi
|
||||
# Check if declared path is a suffix of the modified file path
|
||||
case "$FILE" in
|
||||
*/"$DECL") MATCH=1; break ;;
|
||||
esac
|
||||
done <<< "$DECLARED"
|
||||
|
||||
if [ "$MATCH" -eq 0 ]; then
|
||||
# Exclude files that are always legitimate outside scope
|
||||
case "$BASENAME" in
|
||||
*.spec.md|*.test.*|*Test.*|*Tests.*|*_test.*|test_*) continue ;;
|
||||
*.md|*.json|*.yml|*.yaml|*.lock) continue ;;
|
||||
.gitignore|Dockerfile|docker-compose*|*.csproj|*.sln|package.json) continue ;;
|
||||
esac
|
||||
case "$FILE" in
|
||||
*/test/*|*/tests/*|*/Test/*|*/Tests/*|*/__tests__/*) continue ;;
|
||||
esac
|
||||
OUT_OF_SCOPE="${OUT_OF_SCOPE} - ${FILE}\n"
|
||||
fi
|
||||
done <<< "$MODIFIED"
|
||||
|
||||
if [ -n "$OUT_OF_SCOPE" ]; then
|
||||
echo "SCOPE GUARD: Files modified OUTSIDE the scope of active spec ($(basename "$SPEC_FILE")):" >&2
|
||||
printf "%b" "$OUT_OF_SCOPE" >&2
|
||||
echo "" >&2
|
||||
echo "Review if these changes are intentional or if the agent expanded the scope." >&2
|
||||
# Warning only, does not block
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"description": "Test-Driven Development enforcement hook. Blocks editing production code files (.cs, .py, .ts, .go, .rs, .rb, .php, .java, .kt, .swift, .dart) unless a corresponding test file exists. Forces the TDD workflow: write tests first, then implement. Automatically skips config files, migrations, DTOs, test files themselves, and infrastructure files. Looks for test files with common naming patterns (MyClassTest.ext, my-class.test.ext, my_class_test.ext, test_my_class.ext). Inspired by pm-workspace's Spec-Driven Development methodology.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "tdd-gate.sh",
|
||||
"destination": ".claude/hooks/tdd-gate.sh",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-gate.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-gate.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-gate.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# tdd-gate.sh — Enforce Test-Driven Development: tests must exist before production code
|
||||
# Source: pm-workspace (https://github.com/gonzalezpazmonica/pm-workspace)
|
||||
# License: MIT
|
||||
#
|
||||
# Blocks editing production code if no corresponding test file exists.
|
||||
# Forces developers and AI agents to write tests FIRST, then implement.
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
||||
FILE_PATH=""
|
||||
|
||||
# Applies to Edit, MultiEdit and Write tools
|
||||
case "$TOOL" in
|
||||
Edit|MultiEdit|Write)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -z "$FILE_PATH" ] && exit 0
|
||||
|
||||
# Get file extension
|
||||
EXT="${FILE_PATH##*.}"
|
||||
|
||||
# Only check production code extensions
|
||||
case "$EXT" in
|
||||
cs|py|ts|tsx|js|jsx|go|rs|rb|php|java|kt|swift|dart) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Skip files that don't need TDD gate (precise patterns to avoid false skips)
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
case "$BASENAME" in
|
||||
# Explicit test file patterns — suffix-based, not substring
|
||||
*Test.${EXT}|*Tests.${EXT}|*_test.${EXT}|test_*.${EXT}) exit 0 ;;
|
||||
*.test.${EXT}|*.spec.${EXT}|*Spec.${EXT}|*Specs.${EXT}) exit 0 ;;
|
||||
# Config, migration, and infrastructure files
|
||||
*Migration*|*migration*|*.dto.*|*DTO*) exit 0 ;;
|
||||
*Program.cs|*Startup.cs|*appsettings*|*.csproj|*.sln) exit 0 ;;
|
||||
*.d.ts|*.config.ts|*.config.js|tsconfig*|package.json) exit 0 ;;
|
||||
Dockerfile|docker-compose*|*.tf|*.tfvars|*.yml|*.yaml) exit 0 ;;
|
||||
*.md|*.txt|*.json|*.xml|*.html|*.css|*.scss) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Skip paths that are not production code
|
||||
case "$FILE_PATH" in
|
||||
*/test/*|*/tests/*|*/Test/*|*/Tests/*|*/__tests__/*) exit 0 ;;
|
||||
*/spec/*|*/specs/*|*/Spec/*|*/Specs/*) exit 0 ;;
|
||||
*/fixtures/*|*/mocks/*|*/stubs/*|*/fakes/*) exit 0 ;;
|
||||
*/migrations/*|*/Migrations/*|*/seeds/*) exit 0 ;;
|
||||
*/config/*|*/Config/*|*/scripts/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Search for corresponding test file in the same directory first, then nearby
|
||||
NAME_NO_EXT="${BASENAME%.*}"
|
||||
FILE_DIR=$(dirname "$FILE_PATH")
|
||||
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||
|
||||
# Search strategy: first in nearby dirs (same module), then project-wide
|
||||
# Use -maxdepth to limit latency on large repos
|
||||
TESTS_FOUND=$(find "$FILE_DIR" "$FILE_DIR/../test" "$FILE_DIR/../tests" "$FILE_DIR/../Test" "$FILE_DIR/../Tests" "$FILE_DIR/../__tests__" -maxdepth 2 -type f \( \
|
||||
-name "${NAME_NO_EXT}Test.*" -o \
|
||||
-name "${NAME_NO_EXT}Tests.*" -o \
|
||||
-name "${NAME_NO_EXT}.test.*" -o \
|
||||
-name "${NAME_NO_EXT}.spec.*" -o \
|
||||
-name "${NAME_NO_EXT}_test.*" -o \
|
||||
-name "test_${NAME_NO_EXT}.*" \
|
||||
\) 2>/dev/null | head -1)
|
||||
|
||||
# Fallback: project-wide search with depth limit
|
||||
if [ -z "$TESTS_FOUND" ]; then
|
||||
TESTS_FOUND=$(find "$PROJECT_ROOT" -maxdepth 6 -type f \( \
|
||||
-name "${NAME_NO_EXT}Test.*" -o \
|
||||
-name "${NAME_NO_EXT}Tests.*" -o \
|
||||
-name "${NAME_NO_EXT}.test.*" -o \
|
||||
-name "${NAME_NO_EXT}.spec.*" -o \
|
||||
-name "${NAME_NO_EXT}_test.*" -o \
|
||||
-name "test_${NAME_NO_EXT}.*" \
|
||||
\) 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$TESTS_FOUND" ]; then
|
||||
echo "TDD GATE: No tests found for '$BASENAME'. Write tests BEFORE implementing production code. Create: ${NAME_NO_EXT}Test.${EXT} or ${NAME_NO_EXT}.test.${EXT}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "AI-powered bash command security guard. Before any Bash command runs, a lightweight Claude subagent evaluates it for destructive or irreversible patterns — recursive deletes, force pushes to protected branches, database drops, and credential exposure — and blocks execution with a clear explanation if flagged. Uses PreToolUse with type:agent, which is the only hook pattern that can block tool execution via AI reasoning.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "agent",
|
||||
"prompt": "You are a security guard for a developer's terminal. Evaluate the bash command provided in the tool input for destructive or irreversible risk.\n\nDENY the command if it matches ANY of these patterns:\n- Recursive deletion outside /tmp: rm -rf on non-temporary paths\n- Force push to protected branches: git push --force or -f targeting main, master, develop, or production\n- Destructive database operations without a WHERE clause: DROP TABLE, TRUNCATE, DELETE on production-named databases\n- Credential exposure: commands that write environment variables containing KEY, TOKEN, SECRET, or PASSWORD to files or network destinations\n- Disk-level destruction: dd, shred, or mkfs targeting non-loop devices\n\nALLOW everything else, including rm on /tmp, force pushes to personal feature branches, and DROP TABLE in databases named test, dev, or local.\n\nRespond ONLY with a JSON object in exactly this format:\n\nIf DENYING:\n{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"deny\", \"permissionDecisionReason\": \"<one sentence: what is dangerous and what to do instead>\"}}\n\nIf ALLOWING:\n{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"allow\"}}",
|
||||
"timeout": 20,
|
||||
"model": "haiku"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "Advanced protection against dangerous shell commands with multi-level security. Blocks catastrophic operations (rm -rf /, dd, mkfs), protects critical paths (.claude/, .git/, node_modules/), and warns about suspicious patterns. Features: catastrophic command blocking, critical path protection, smart pattern detection, and detailed safety messages.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "dangerous-command-blocker.py",
|
||||
"destination": ".claude/hooks/dangerous-command-blocker.py",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/dangerous-command-blocker.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dangerous Command Blocker Hook
|
||||
Multi-level security system for blocking dangerous shell commands
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Load command from stdin
|
||||
data = json.load(sys.stdin)
|
||||
cmd = data.get('tool_input', {}).get('command', '')
|
||||
|
||||
# === LEVEL 1: CATASTROPHIC COMMANDS (ALWAYS BLOCK) ===
|
||||
catastrophic_patterns = [
|
||||
(r'\brm\s+.*\s+/\s*$', 'rm on root directory'),
|
||||
(r'\brm\s+.*\s+~\s*$', 'rm on home directory'),
|
||||
(r'\brm\s+.*\s+\*\s*$', 'rm with star wildcard'),
|
||||
(r'\brm\s+-[rfRF]*[rfRF]+.*\*', 'rm -rf with wildcards'),
|
||||
(r'\b(dd\s+if=|dd\s+of=/dev)', 'dd disk operations'),
|
||||
(r'\b(mkfs\.|mkswap\s|fdisk\s)', 'filesystem formatting'),
|
||||
(r'\b:(\(\))?\s*\{\s*:\s*\|\s*:\s*&\s*\}', 'fork bomb'),
|
||||
(r'>\s*/dev/sd[a-z]', 'direct disk write'),
|
||||
(r'\bchmod\s+(-R\s+)?777\s+/', 'chmod 777 on root'),
|
||||
(r'\bchown\s+(-R\s+)?.*\s+/$', 'chown on root directory'),
|
||||
]
|
||||
|
||||
for pattern, desc in catastrophic_patterns:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'❌ BLOCKED: Catastrophic command detected!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Reason: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This command could cause IRREVERSIBLE system damage or data loss.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Safety tips:', file=sys.stderr)
|
||||
print(f' • Never use rm -rf with /, ~, or * wildcards', file=sys.stderr)
|
||||
print(f' • Avoid recursive operations on system directories', file=sys.stderr)
|
||||
print(f' • Use specific file paths instead of wildcards', file=sys.stderr)
|
||||
print(f' • For cleanup, target specific directories: rm -rf /tmp/myproject', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# === LEVEL 2: CRITICAL PATH PROTECTION ===
|
||||
critical_paths = [
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?\.claude(/|$|\s)', 'Claude Code configuration'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?\.git(/|$|\s)', 'Git repository'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?node_modules(/|$|\s)', 'Node.js dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*\.env\b', 'Environment variables'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*package\.json\b', 'Package manifest'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*package-lock\.json\b', 'Lock file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*yarn\.lock\b', 'Yarn lock file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*Cargo\.toml\b', 'Rust manifest'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*go\.mod\b', 'Go module file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*requirements\.txt\b', 'Python dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*Gemfile(\.lock)?\b', 'Ruby dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*composer\.json\b', 'PHP dependencies'),
|
||||
]
|
||||
|
||||
for pattern, desc in critical_paths:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'🛑 BLOCKED: Critical path protection activated!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Protected resource: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This path contains critical project files that should not be deleted accidentally.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'If you really need to modify this:', file=sys.stderr)
|
||||
print(f' 1. Disable the hook temporarily in .claude/hooks.json', file=sys.stderr)
|
||||
print(f' 2. Execute the command manually in your terminal', file=sys.stderr)
|
||||
print(f' 3. Or modify specific files instead of using rm/mv on entire directories', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# === LEVEL 3: SUSPICIOUS PATTERNS (WARNING) ===
|
||||
suspicious_patterns = [
|
||||
(r'\brm\s+.*\s+&&', 'chained rm commands'),
|
||||
(r'\brm\s+[^\s/]*\*', 'rm with wildcards'),
|
||||
(r'\bfind\s+.*-delete', 'find -delete operation'),
|
||||
(r'\bxargs\s+.*\brm', 'xargs with rm'),
|
||||
]
|
||||
|
||||
for pattern, desc in suspicious_patterns:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'⚠️ WARNING: Suspicious command pattern detected!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Pattern: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This command uses patterns that could accidentally delete more than intended.', file=sys.stderr)
|
||||
print(f'Consider reviewing the command carefully before execution.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
# Exit 0 to allow but with warning
|
||||
sys.exit(0)
|
||||
|
||||
# Command is safe
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Prevent writing to .env files using the if condition for lightweight filtering. Blocks any Write tool call targeting .env* files, protecting secrets from accidental overwrites. Uses the if field to avoid spawning a process unless the file pattern matches.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Write(.env*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Writing to .env files is blocked by hook\"}}'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Protect critical files from accidental modification. Prevents editing of important system files, configuration files, and production code.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "PROTECTED_PATTERNS=('*/etc/*' '*/usr/bin/*' '*/usr/sbin/*' '*.production.*' '*prod*config*' '*/node_modules/*' '*/vendor/*'); for pattern in \"${PROTECTED_PATTERNS[@]}\"; do if [[ \"$CLAUDE_TOOL_FILE_PATH\" == $pattern ]]; then echo \"Error: File $CLAUDE_TOOL_FILE_PATH is protected from modification\" >&2; exit 1; fi; done"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "Block git force push commands using the if condition for efficient filtering. Prevents accidental force pushes that can overwrite remote history. Covers --force, --force-with-lease, and the -f shorthand.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(git push *--force*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Force push is blocked by hook\"}}'"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(git push *-f*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Force push (-f) is blocked by hook\"}}'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically detects hardcoded secrets before git commits. Scans for API keys from 30+ providers (Anthropic: sk-ant-..., OpenAI: sk-..., AWS: AKIA..., Stripe: sk_live_..., Google: AIza..., GitHub: ghp_..., Vercel, Supabase, Hugging Face: hf_..., Replicate: r8_..., Groq: gsk_..., Databricks: dapi..., GitLab, DigitalOcean, npm, PyPI, and more), tokens, passwords, private keys, and database credentials. Blocks commits containing secrets and suggests using environment variables instead.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/secret-scanner.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Secret Scanner Hook
|
||||
Detects hardcoded secrets before git commits
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# Secret detection patterns with descriptions
|
||||
SECRET_PATTERNS = [
|
||||
# AWS Keys
|
||||
(r'AKIA[0-9A-Z]{16}', 'AWS Access Key ID', 'high'),
|
||||
(r'(?i)aws[_\-\s]*secret[_\-\s]*access[_\-\s]*key[\'"\s]*[=:][\'"\s]*[A-Za-z0-9/+=]{40}', 'AWS Secret Access Key', 'high'),
|
||||
|
||||
# Anthropic (Claude) API Keys
|
||||
(r'sk-ant-api\d{2}-[A-Za-z0-9\-_]{20,}', 'Anthropic API Key', 'high'),
|
||||
|
||||
# OpenAI API Keys
|
||||
(r'sk-[a-zA-Z0-9]{48,}', 'OpenAI API Key', 'high'),
|
||||
(r'sk-proj-[a-zA-Z0-9\-_]{32,}', 'OpenAI Project API Key', 'high'),
|
||||
|
||||
# Google API Keys & Service Accounts
|
||||
(r'AIza[0-9A-Za-z\-_]{35}', 'Google API Key', 'high'),
|
||||
(r'ya29\.[0-9A-Za-z\-_]+', 'Google OAuth Access Token', 'high'),
|
||||
|
||||
# Stripe API Keys
|
||||
(r'sk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Secret Key', 'critical'),
|
||||
(r'sk_test_[0-9a-zA-Z]{24,}', 'Stripe Test Secret Key', 'medium'),
|
||||
(r'rk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Restricted Key', 'high'),
|
||||
(r'pk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Publishable Key', 'medium'),
|
||||
|
||||
# GitHub Tokens
|
||||
(r'ghp_[0-9a-zA-Z]{36}', 'GitHub Personal Access Token', 'high'),
|
||||
(r'gho_[0-9a-zA-Z]{36}', 'GitHub OAuth Token', 'high'),
|
||||
(r'ghs_[0-9a-zA-Z]{36}', 'GitHub App Secret', 'high'),
|
||||
(r'ghr_[0-9a-zA-Z]{36}', 'GitHub Refresh Token', 'high'),
|
||||
(r'github_pat_[0-9a-zA-Z_]{22,}', 'GitHub Fine-Grained PAT', 'high'),
|
||||
|
||||
# GitLab Tokens
|
||||
(r'glpat-[0-9a-zA-Z\-_]{20,}', 'GitLab Personal Access Token', 'high'),
|
||||
|
||||
# Vercel Tokens
|
||||
(r'vercel_[0-9a-zA-Z_\-]{24,}', 'Vercel Token', 'high'),
|
||||
|
||||
# Supabase Keys
|
||||
(r'sbp_[0-9a-f]{40}', 'Supabase Service Key', 'high'),
|
||||
(r'sb_publishable_[A-Za-z0-9\-_]{20,}', 'Supabase Publishable Key', 'medium'),
|
||||
(r'sb_secret_[A-Za-z0-9\-_]{20,}', 'Supabase Secret Key', 'high'),
|
||||
|
||||
# Hugging Face Tokens
|
||||
(r'hf_[a-zA-Z0-9]{34,}', 'Hugging Face Token', 'high'),
|
||||
|
||||
# Replicate API Tokens
|
||||
(r'r8_[a-zA-Z0-9]{38,}', 'Replicate API Token', 'high'),
|
||||
|
||||
# Groq API Keys
|
||||
(r'gsk_[a-zA-Z0-9]{48,}', 'Groq API Key', 'high'),
|
||||
|
||||
# Databricks Personal Access Tokens
|
||||
(r'dapi[0-9a-f]{32}', 'Databricks Access Token', 'high'),
|
||||
|
||||
# Azure Keys
|
||||
(r'(?i)azure[_\-\s]*(?:key|secret|token)[\'"\s]*[=:][\'"\s]*[A-Za-z0-9+/=]{32,}', 'Azure Key', 'high'),
|
||||
|
||||
# Cloudflare API Tokens
|
||||
(r'(?:cf|cloudflare)[_\-]?[A-Za-z0-9_\-]{37,}', 'Cloudflare API Token', 'medium'),
|
||||
|
||||
# DigitalOcean Tokens
|
||||
(r'dop_v1_[0-9a-f]{64}', 'DigitalOcean Personal Access Token', 'high'),
|
||||
(r'doo_v1_[0-9a-f]{64}', 'DigitalOcean OAuth Token', 'high'),
|
||||
|
||||
# Linear API Keys
|
||||
(r'lin_api_[a-zA-Z0-9]{40,}', 'Linear API Key', 'high'),
|
||||
|
||||
# Notion API Keys
|
||||
(r'ntn_[0-9a-zA-Z]{40,}', 'Notion Integration Token', 'high'),
|
||||
(r'secret_[0-9a-zA-Z]{43}', 'Notion API Key (legacy)', 'high'),
|
||||
|
||||
# Figma Access Tokens
|
||||
(r'figd_[0-9a-zA-Z\-_]{40,}', 'Figma Access Token', 'high'),
|
||||
|
||||
# npm Tokens
|
||||
(r'npm_[0-9a-zA-Z]{36,}', 'npm Access Token', 'high'),
|
||||
|
||||
# PyPI API Tokens
|
||||
(r'pypi-[A-Za-z0-9\-_]{16,}', 'PyPI API Token', 'high'),
|
||||
|
||||
# Generic API Keys
|
||||
(r'(?i)(api[_\-\s]*key|apikey)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic API Key', 'medium'),
|
||||
(r'(?i)(secret[_\-\s]*key|secretkey)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic Secret Key', 'medium'),
|
||||
(r'(?i)(access[_\-\s]*token|accesstoken)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic Access Token', 'medium'),
|
||||
|
||||
# Passwords
|
||||
(r'(?i)password[\'"\s]*[=:][\'"\s]*[\'"][^\'"\s]{8,}[\'"]', 'Hardcoded Password', 'high'),
|
||||
(r'(?i)passwd[\'"\s]*[=:][\'"\s]*[\'"][^\'"\s]{8,}[\'"]', 'Hardcoded Password', 'high'),
|
||||
|
||||
# Private Keys
|
||||
(r'-----BEGIN (RSA |DSA |EC )?PRIVATE KEY-----', 'Private Key', 'critical'),
|
||||
(r'-----BEGIN OPENSSH PRIVATE KEY-----', 'OpenSSH Private Key', 'critical'),
|
||||
|
||||
# Database Connection Strings
|
||||
(r'(?i)(mysql|postgresql|postgres|mongodb)://[^\s\'"\)]+:[^\s\'"\)]+@', 'Database Connection String', 'high'),
|
||||
(r'(?i)Server=[^;]+;Database=[^;]+;User Id=[^;]+;Password=[^;]+', 'SQL Server Connection String', 'high'),
|
||||
|
||||
# JWT Tokens
|
||||
(r'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}', 'JWT Token', 'medium'),
|
||||
|
||||
# Slack Tokens
|
||||
(r'xox[baprs]-[0-9a-zA-Z\-]{10,}', 'Slack Token', 'high'),
|
||||
|
||||
# Telegram Bot Tokens
|
||||
(r'[0-9]{8,10}:[A-Za-z0-9_\-]{35}', 'Telegram Bot Token', 'medium'),
|
||||
|
||||
# Discord Webhooks
|
||||
(r'https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_\-]+', 'Discord Webhook URL', 'medium'),
|
||||
|
||||
# Twilio API Keys
|
||||
(r'SK[0-9a-fA-F]{32}', 'Twilio API Key', 'high'),
|
||||
|
||||
# SendGrid API Keys
|
||||
(r'SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}', 'SendGrid API Key', 'high'),
|
||||
|
||||
# Mailgun API Keys
|
||||
(r'key-[0-9a-zA-Z]{32}', 'Mailgun API Key', 'medium'),
|
||||
|
||||
# Heroku API Keys
|
||||
(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', 'Potential API Key (UUID format)', 'low'),
|
||||
]
|
||||
|
||||
# Files to exclude from scanning
|
||||
EXCLUDED_FILES = [
|
||||
'.env.example',
|
||||
'.env.sample',
|
||||
'.env.template',
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'poetry.lock',
|
||||
'Pipfile.lock',
|
||||
'Cargo.lock',
|
||||
'go.sum',
|
||||
'.gitignore',
|
||||
]
|
||||
|
||||
# Directories to exclude
|
||||
EXCLUDED_DIRS = [
|
||||
'node_modules/',
|
||||
'vendor/',
|
||||
'.git/',
|
||||
'dist/',
|
||||
'build/',
|
||||
'__pycache__/',
|
||||
'.pytest_cache/',
|
||||
'venv/',
|
||||
'env/',
|
||||
]
|
||||
|
||||
def should_skip_file(file_path):
|
||||
"""Check if file should be skipped"""
|
||||
# Skip if file doesn't exist (might be deleted)
|
||||
if not os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
# Skip excluded files
|
||||
filename = os.path.basename(file_path)
|
||||
if filename in EXCLUDED_FILES:
|
||||
return True
|
||||
|
||||
# Skip excluded directories
|
||||
for excluded_dir in EXCLUDED_DIRS:
|
||||
if excluded_dir in file_path:
|
||||
return True
|
||||
|
||||
# Skip binary files
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
chunk = f.read(1024)
|
||||
if b'\0' in chunk:
|
||||
return True
|
||||
except:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_staged_files():
|
||||
"""Get list of staged files"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return [f.strip() for f in result.stdout.split('\n') if f.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
def scan_file(file_path):
|
||||
"""Scan a single file for secrets"""
|
||||
findings = []
|
||||
|
||||
if should_skip_file(file_path):
|
||||
return findings
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
for line_num, line in enumerate(content.split('\n'), 1):
|
||||
for pattern, description, severity in SECRET_PATTERNS:
|
||||
matches = re.finditer(pattern, line)
|
||||
for match in matches:
|
||||
# Skip if it looks like a comment or example
|
||||
line_stripped = line.strip()
|
||||
if line_stripped.startswith('#') or line_stripped.startswith('//'):
|
||||
# Check if it's actually a comment with example
|
||||
if 'example' in line_stripped.lower() or 'placeholder' in line_stripped.lower():
|
||||
continue
|
||||
|
||||
findings.append({
|
||||
'file': file_path,
|
||||
'line': line_num,
|
||||
'description': description,
|
||||
'severity': severity,
|
||||
'match': match.group(0)[:50] + '...' if len(match.group(0)) > 50 else match.group(0),
|
||||
'full_line': line.strip()[:100]
|
||||
})
|
||||
except Exception as e:
|
||||
# Skip files that can't be read
|
||||
pass
|
||||
|
||||
return findings
|
||||
|
||||
def print_findings(findings):
|
||||
"""Print findings in a formatted way"""
|
||||
if not findings:
|
||||
return
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||
findings.sort(key=lambda x: (severity_order.get(x['severity'], 4), x['file'], x['line']))
|
||||
|
||||
print('', file=sys.stderr)
|
||||
print('🚨 SECRET SCANNER: Potential secrets detected!', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
critical_count = sum(1 for f in findings if f['severity'] == 'critical')
|
||||
high_count = sum(1 for f in findings if f['severity'] == 'high')
|
||||
medium_count = sum(1 for f in findings if f['severity'] == 'medium')
|
||||
low_count = sum(1 for f in findings if f['severity'] == 'low')
|
||||
|
||||
print(f'Found {len(findings)} potential secret(s):', file=sys.stderr)
|
||||
if critical_count > 0:
|
||||
print(f' 🔴 Critical: {critical_count}', file=sys.stderr)
|
||||
if high_count > 0:
|
||||
print(f' 🟠 High: {high_count}', file=sys.stderr)
|
||||
if medium_count > 0:
|
||||
print(f' 🟡 Medium: {medium_count}', file=sys.stderr)
|
||||
if low_count > 0:
|
||||
print(f' 🔵 Low: {low_count}', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
for finding in findings:
|
||||
severity_emoji = {
|
||||
'critical': '🔴',
|
||||
'high': '🟠',
|
||||
'medium': '🟡',
|
||||
'low': '🔵'
|
||||
}.get(finding['severity'], '⚪')
|
||||
|
||||
print(f'{severity_emoji} {finding["description"]}', file=sys.stderr)
|
||||
print(f' File: {finding["file"]}:{finding["line"]}', file=sys.stderr)
|
||||
print(f' Match: {finding["match"]}', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
print('❌ COMMIT BLOCKED: Remove secrets before committing', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print('How to fix:', file=sys.stderr)
|
||||
print(' 1. Move secrets to environment variables:', file=sys.stderr)
|
||||
print(' • Create/update .env file (ensure it\'s in .gitignore)', file=sys.stderr)
|
||||
print(' • Use process.env.SECRET_NAME (Node.js) or os.environ.get("SECRET_NAME") (Python)', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print(' 2. Use a secrets management service:', file=sys.stderr)
|
||||
print(' • AWS Secrets Manager, Google Secret Manager, Azure Key Vault', file=sys.stderr)
|
||||
print(' • HashiCorp Vault, Doppler, 1Password', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print(' 3. For false positives:', file=sys.stderr)
|
||||
print(' • Add comments with "example" or "placeholder" to skip detection', file=sys.stderr)
|
||||
print(' • Disable hook temporarily: remove from .claude/hooks.json', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
def main():
|
||||
# Read hook input from stdin (Claude Code passes JSON via stdin)
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# If no valid JSON on stdin, allow the action
|
||||
sys.exit(0)
|
||||
|
||||
# Only act on git commit commands
|
||||
tool_input = input_data.get('tool_input', {})
|
||||
command = tool_input.get('command', '')
|
||||
if not re.search(r'git\s+commit', command):
|
||||
sys.exit(0)
|
||||
|
||||
# Get staged files
|
||||
staged_files = get_staged_files()
|
||||
|
||||
# PreToolUse runs before the command, so files may not be staged yet.
|
||||
# Handle two cases:
|
||||
# 1. git commit -a/-am: scans tracked modified files (what -a would stage)
|
||||
# 2. git add ... && git commit: scans files from the git add part
|
||||
if not staged_files:
|
||||
# Check if commit uses -a flag (auto-stage tracked modified files)
|
||||
commit_match = re.search(r'git\s+commit\s+(.+)', command)
|
||||
if commit_match and re.search(r'-\w*a', commit_match.group(1)):
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--name-only'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for f in result.stdout.strip().split('\n'):
|
||||
if f.strip() and os.path.isfile(f.strip()):
|
||||
staged_files.append(f.strip())
|
||||
|
||||
# Check for chained git add ... && git commit
|
||||
for part in re.split(r'&&|;', command):
|
||||
part = part.strip()
|
||||
add_match = re.match(r'git\s+add\s+(.+)', part)
|
||||
if add_match:
|
||||
args = add_match.group(1).strip()
|
||||
if args in ('.', '-A', '--all'):
|
||||
result = subprocess.run(
|
||||
['git', 'status', '--porcelain'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line and len(line) > 3:
|
||||
f = line[3:].strip()
|
||||
if os.path.isfile(f):
|
||||
staged_files.append(f)
|
||||
else:
|
||||
for token in args.split():
|
||||
if not token.startswith('-') and os.path.isfile(token):
|
||||
staged_files.append(token)
|
||||
|
||||
if not staged_files:
|
||||
sys.exit(0)
|
||||
|
||||
# Scan all staged files
|
||||
all_findings = []
|
||||
for file_path in staged_files:
|
||||
findings = scan_file(file_path)
|
||||
all_findings.extend(findings)
|
||||
|
||||
# If we found any secrets, block the commit
|
||||
if all_findings:
|
||||
print_findings(all_findings)
|
||||
sys.exit(2)
|
||||
|
||||
# No secrets found, allow commit
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Scan code for security vulnerabilities and secrets after modifications. Uses multiple security tools to detect potential issues.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if command -v semgrep >/dev/null 2>&1; then semgrep --config=auto \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi; if command -v bandit >/dev/null 2>&1 && [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then bandit \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi; if command -v gitleaks >/dev/null 2>&1; then gitleaks detect --source=\"$CLAUDE_TOOL_FILE_PATH\" --no-git 2>/dev/null || true; fi; if grep -qE '(password|secret|key|token)\\s*=\\s*[\"\\x27][^\"\\x27]{8,}' \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null; then echo \"Warning: Potential hardcoded secrets detected in $CLAUDE_TOOL_FILE_PATH\" >&2; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "Detects destructive commands hidden inside shell wrappers (sh -c, bash -c, python3 -c, node -e, perl -e, ruby -e). Complements dangerous-command-blocker by catching bypass vectors like 'sh -c \"rm -rf /\"' that evade direct command checks. Covers 8 bypass patterns: interpreter one-liners, nested wrappers, pipe-to-shell, here-strings, and env-based wrappers.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "shell-wrapper-guard.sh",
|
||||
"destination": ".claude/hooks/shell-wrapper-guard.sh",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/shell-wrapper-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# shell-wrapper-guard.sh — Detect destructive commands hidden in shell wrappers
|
||||
#
|
||||
# Solves: Bypass vectors that evade destructive-guard by wrapping commands:
|
||||
# sh -c "rm -rf /"
|
||||
# bash -c "git reset --hard"
|
||||
# python3 -c "import os; os.system('rm -rf ~')"
|
||||
# perl -e "system('rm -rf /')"
|
||||
# ruby -e "system('rm -rf /')"
|
||||
# node -e "require('child_process').execSync('rm -rf /')"
|
||||
#
|
||||
# Complements destructive-guard.sh which checks direct commands.
|
||||
# This hook unwraps interpreter one-liners and checks the inner command.
|
||||
#
|
||||
# Usage: PreToolUse hook on "Bash"
|
||||
#
|
||||
# TRIGGER: PreToolUse MATCHER: "Bash"
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
[ -z "$COMMAND" ] && exit 0
|
||||
|
||||
# Destructive patterns to detect inside wrappers
|
||||
DESTRUCT_PATTERN='rm\s+-[rf]*\s+[/~]|rm\s+-[rf]*\s+\.\.|git\s+reset\s+--hard|git\s+clean\s+-[fd]+|git\s+checkout\s+\.|mkfs\.|dd\s+if=|>\s*/dev/sd|chmod\s+777\s+/'
|
||||
|
||||
# === Check 1: sh/bash -c wrappers ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash|zsh|dash)\s+-c\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh|dash)\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command hidden in shell wrapper" >&2
|
||||
echo " Detected: $INNER" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 2: Python one-liners ===
|
||||
if echo "$COMMAND" | grep -qE 'python[23]?(\.[0-9]+)?\s+-c\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*python[23]?(\.[0-9]+)?\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qiE "os\.system\(.*($DESTRUCT_PATTERN)|subprocess\.(run|call|Popen)\(.*($DESTRUCT_PATTERN)|shutil\.rmtree\s*\(\s*['\"/~]"; then
|
||||
echo "BLOCKED: Destructive command in Python one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 3: Perl/Ruby one-liners ===
|
||||
if echo "$COMMAND" | grep -qE '(perl|ruby)\s+-e\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(perl|ruby)\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "system\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
|
||||
echo "BLOCKED: Destructive command in interpreter one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 4: Node.js one-liners ===
|
||||
if echo "$COMMAND" | grep -qE 'node\s+-e\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*node\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "execSync\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
|
||||
echo "BLOCKED: Destructive command in Node.js one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 5: Nested wrappers (sh -c "bash -c 'rm -rf /'") ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash)\s+-c\s+.*(sh|bash)\s+-c'; then
|
||||
if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Nested shell wrapper with destructive command" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 6: Pipe to shell (echo "rm -rf /" | sh) ===
|
||||
if echo "$COMMAND" | grep -qE '\|\s*(sh|bash|zsh)(\s|$)'; then
|
||||
# Extract the piped content (handles "| sh", "| sh -s", "| bash -c ...")
|
||||
PIPED=$(echo "$COMMAND" | sed -E 's/\s*\|\s*(sh|bash|zsh)(\s.*)?$//')
|
||||
if echo "$PIPED" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command piped to shell" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 7: Here-string to shell (bash <<< "rm -rf /") ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash|zsh)\s+<<<\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh)\s+<<<\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command via here-string" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 8: env-based bypass (env VAR=val sh -c "$VAR") ===
|
||||
if echo "$COMMAND" | grep -qE '^\s*env\s+.*\s+(sh|bash)\s+-c'; then
|
||||
if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command via env wrapper" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically run relevant tests after code changes. Detects test files and runs appropriate test commands based on file extensions and project structure.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.js || \"$CLAUDE_TOOL_FILE_PATH\" == *.ts ]] && [[ -f package.json ]]; then npm test 2>/dev/null || yarn test 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]] && [[ -f pytest.ini || -f setup.cfg || -f pyproject.toml ]]; then pytest \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || python -m pytest \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; elif [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.rb ]] && [[ -f Gemfile ]]; then bundle exec rspec \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user