From 68f90e05dd919b626c43351624fd0dc10c70b341 Mon Sep 17 00:00:00 2001 From: "Daisy S. Hollman" Date: Sun, 16 Nov 2025 13:53:45 -0800 Subject: [PATCH 1/2] feat: Add ralph-wiggum plugin for iterative self-referential development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the ralph-wiggum plugin from internal marketplace to public marketplace. Implements Geoffrey Huntley's "Ralph Wiggum" technique using Claude Code's Stop hook mechanism for continuous iterative development loops. Key features: - Interactive self-referential AI loops in current session - Stop hook intercepts exit and feeds same prompt back - Iteration tracking and completion promise detection - Max iterations safety limits Changes: - Remove all tmux dependencies and background execution mode - Simplify to interactive-only mode using Stop hooks - Add comprehensive error handling with clear messages - Fix documentation to accurately describe Stop hook mechanism - Add input validation for all command-line arguments - Register plugin in public marketplace Security fixes: - Remove eval usage (command injection vulnerability) - Add numeric validation before arithmetic operations - Remove silent error suppression 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 11 ++ .../ralph-wiggum/.claude-plugin/plugin.json | 9 + plugins/ralph-wiggum/README.md | 179 ++++++++++++++++++ plugins/ralph-wiggum/commands/cancel-ralph.md | 26 +++ plugins/ralph-wiggum/commands/help.md | 126 ++++++++++++ plugins/ralph-wiggum/commands/ralph-loop.md | 48 +++++ plugins/ralph-wiggum/hooks/hooks.json | 15 ++ plugins/ralph-wiggum/hooks/stop-hook.sh | 177 +++++++++++++++++ .../ralph-wiggum/scripts/setup-ralph-loop.sh | 176 +++++++++++++++++ 9 files changed, 767 insertions(+) create mode 100644 plugins/ralph-wiggum/.claude-plugin/plugin.json create mode 100644 plugins/ralph-wiggum/README.md create mode 100644 plugins/ralph-wiggum/commands/cancel-ralph.md create mode 100644 plugins/ralph-wiggum/commands/help.md create mode 100644 plugins/ralph-wiggum/commands/ralph-loop.md create mode 100644 plugins/ralph-wiggum/hooks/hooks.json create mode 100755 plugins/ralph-wiggum/hooks/stop-hook.sh create mode 100755 plugins/ralph-wiggum/scripts/setup-ralph-loop.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d4fc7eb4..a99123f6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -101,6 +101,17 @@ }, "source": "./plugins/frontend-design", "category": "development" + }, + { + "name": "ralph-wiggum", + "description": "Interactive self-referential AI loops for iterative development. Claude works on the same task repeatedly, seeing its previous work, until completion.", + "version": "1.0.0", + "author": { + "name": "Daisy Hollman", + "email": "daisy@anthropic.com" + }, + "source": "./plugins/ralph-wiggum", + "category": "development" } ] } diff --git a/plugins/ralph-wiggum/.claude-plugin/plugin.json b/plugins/ralph-wiggum/.claude-plugin/plugin.json new file mode 100644 index 00000000..ec19b401 --- /dev/null +++ b/plugins/ralph-wiggum/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "ralph-wiggum", + "version": "1.0.0", + "description": "Implementation of the Ralph Wiggum technique - continuous self-referential AI loops for interactive iterative development. Run Claude in a while-true loop with the same prompt until task completion.", + "author": { + "name": "Daisy Hollman", + "email": "daisy@anthropic.com" + } +} diff --git a/plugins/ralph-wiggum/README.md b/plugins/ralph-wiggum/README.md new file mode 100644 index 00000000..a65f0108 --- /dev/null +++ b/plugins/ralph-wiggum/README.md @@ -0,0 +1,179 @@ +# Ralph Wiggum Plugin + +Implementation of the Ralph Wiggum technique for iterative, self-referential AI development loops in Claude Code. + +## What is Ralph? + +Ralph is a development methodology based on continuous AI agent loops. As Geoffrey Huntley describes it: **"Ralph is a Bash loop"** - a simple `while true` that repeatedly feeds an AI agent a prompt file, allowing it to iteratively improve its work until completion. + +The technique is named after Ralph Wiggum from The Simpsons, embodying the philosophy of persistent iteration despite setbacks. + +### Core Concept + +This plugin implements Ralph using a **Stop hook** that intercepts Claude's exit attempts: + +```bash +# You run ONCE: +/ralph-loop "Your task description" --completion-promise "DONE" + +# Then Claude Code automatically: +# 1. Works on the task +# 2. Tries to exit +# 3. Stop hook blocks exit +# 4. Stop hook feeds the SAME prompt back +# 5. Repeat until completion +``` + +The loop happens **inside your current session** - you don't need external bash loops. The Stop hook in `hooks/stop-hook.sh` creates the self-referential feedback loop by blocking normal session exit. + +This creates a **self-referential feedback loop** where: +- The prompt never changes between iterations +- Claude's previous work persists in files +- Each iteration sees modified files and git history +- Claude autonomously improves by reading its own past work in files + +## Quick Start + +```bash +/ralph-loop "Build a REST API for todos. Requirements: CRUD operations, input validation, tests. Output COMPLETE when done." --completion-promise "COMPLETE" --max-iterations 50 +``` + +Claude will: +- Implement the API iteratively +- Run tests and see failures +- Fix bugs based on test output +- Iterate until all requirements met +- Output the completion promise when done + +## Commands + +### /ralph-loop + +Start a Ralph loop in your current session. + +**Usage:** +```bash +/ralph-loop "" --max-iterations --completion-promise "" +``` + +**Options:** +- `--max-iterations ` - Stop after N iterations (default: unlimited) +- `--completion-promise ` - Phrase that signals completion + +### /cancel-ralph + +Cancel the active Ralph loop. + +**Usage:** +```bash +/cancel-ralph +``` + +## Prompt Writing Best Practices + +### 1. Clear Completion Criteria + +❌ Bad: "Build a todo API and make it good." + +✅ Good: +```markdown +Build a REST API for todos. + +When complete: +- All CRUD endpoints working +- Input validation in place +- Tests passing (coverage > 80%) +- README with API docs +- Output: COMPLETE +``` + +### 2. Incremental Goals + +❌ Bad: "Create a complete e-commerce platform." + +✅ Good: +```markdown +Phase 1: User authentication (JWT, tests) +Phase 2: Product catalog (list/search, tests) +Phase 3: Shopping cart (add/remove, tests) + +Output COMPLETE when all phases done. +``` + +### 3. Self-Correction + +❌ Bad: "Write code for feature X." + +✅ Good: +```markdown +Implement feature X following TDD: +1. Write failing tests +2. Implement feature +3. Run tests +4. If any fail, debug and fix +5. Refactor if needed +6. Repeat until all green +7. Output: COMPLETE +``` + +### 4. Escape Hatches + +Always use `--max-iterations` as a safety net to prevent infinite loops on impossible tasks: + +```bash +# Recommended: Always set a reasonable iteration limit +/ralph-loop "Try to implement feature X" --max-iterations 20 + +# In your prompt, include what to do if stuck: +# "After 15 iterations, if not complete: +# - Document what's blocking progress +# - List what was attempted +# - Suggest alternative approaches" +``` + +**Note**: The `--completion-promise` uses exact string matching, so you cannot use it for multiple completion conditions (like "SUCCESS" vs "BLOCKED"). Always rely on `--max-iterations` as your primary safety mechanism. + +## Philosophy + +Ralph embodies several key principles: + +### 1. Iteration > Perfection +Don't aim for perfect on first try. Let the loop refine the work. + +### 2. Failures Are Data +"Deterministically bad" means failures are predictable and informative. Use them to tune prompts. + +### 3. Operator Skill Matters +Success depends on writing good prompts, not just having a good model. + +### 4. Persistence Wins +Keep trying until success. The loop handles retry logic automatically. + +## When to Use Ralph + +**Good for:** +- Well-defined tasks with clear success criteria +- Tasks requiring iteration and refinement (e.g., getting tests to pass) +- Greenfield projects where you can walk away +- Tasks with automatic verification (tests, linters) + +**Not good for:** +- Tasks requiring human judgment or design decisions +- One-shot operations +- Tasks with unclear success criteria +- Production debugging (use targeted debugging instead) + +## Real-World Results + +- Successfully generated 6 repositories overnight in Y Combinator hackathon testing +- One $50k contract completed for $297 in API costs +- Created entire programming language ("cursed") over 3 months using this approach + +## Learn More + +- Original technique: https://ghuntley.com/ralph/ +- Ralph Orchestrator: https://github.com/mikeyobrien/ralph-orchestrator + +## For Help + +Run `/help` in Claude Code for detailed command reference and examples. diff --git a/plugins/ralph-wiggum/commands/cancel-ralph.md b/plugins/ralph-wiggum/commands/cancel-ralph.md new file mode 100644 index 00000000..82130b3e --- /dev/null +++ b/plugins/ralph-wiggum/commands/cancel-ralph.md @@ -0,0 +1,26 @@ +--- +description: "Cancel active Ralph Wiggum loop" +allowed-tools: ["Bash"] +hide-from-slash-command-tool: "true" +--- + +# Cancel Ralph + +```! +if [[ -f .claude/ralph-loop.local.md ]]; then + ITERATION=$(grep '^iteration:' .claude/ralph-loop.local.md | sed 's/iteration: *//') + echo "FOUND_LOOP=true" + echo "ITERATION=$ITERATION" +else + echo "FOUND_LOOP=false" +fi +``` + +Check the output above: + +1. **If FOUND_LOOP=false**: + - Say "No active Ralph loop found." + +2. **If FOUND_LOOP=true**: + - Use Bash: `rm .claude/ralph-loop.local.md` + - Report: "Cancelled Ralph loop (was at iteration N)" where N is the ITERATION value from above. diff --git a/plugins/ralph-wiggum/commands/help.md b/plugins/ralph-wiggum/commands/help.md new file mode 100644 index 00000000..78a5045c --- /dev/null +++ b/plugins/ralph-wiggum/commands/help.md @@ -0,0 +1,126 @@ +--- +description: "Explain Ralph Wiggum technique and available commands" +--- + +# Ralph Wiggum Plugin Help + +Please explain the following to the user: + +## What is the Ralph Wiggum Technique? + +The Ralph Wiggum technique is an iterative development methodology based on continuous AI loops, pioneered by Geoffrey Huntley. + +**Core concept:** +```bash +while :; do + cat PROMPT.md | claude-code --continue +done +``` + +The same prompt is fed to Claude repeatedly. The "self-referential" aspect comes from Claude seeing its own previous work in the files and git history, not from feeding output back as input. + +**Each iteration:** +1. Claude receives the SAME prompt +2. Works on the task, modifying files +3. Tries to exit +4. Stop hook intercepts and feeds the same prompt again +5. Claude sees its previous work in the files +6. Iteratively improves until completion + +The technique is described as "deterministically bad in an undeterministic world" - failures are predictable, enabling systematic improvement through prompt tuning. + +## Available Commands + +### /ralph-loop [OPTIONS] + +Start a Ralph loop in your current session. + +**Usage:** +``` +/ralph-loop "Refactor the cache layer" --max-iterations 20 +/ralph-loop "Add tests" --completion-promise "TESTS COMPLETE" +``` + +**Options:** +- `--max-iterations ` - Max iterations before auto-stop +- `--completion-promise ` - Promise phrase to signal completion + +**How it works:** +1. Creates `.claude/.ralph-loop.local.md` state file +2. You work on the task +3. When you try to exit, stop hook intercepts +4. Same prompt fed back +5. You see your previous work +6. Continues until promise detected or max iterations + +--- + +### /cancel-ralph + +Cancel an active Ralph loop (removes the loop state file). + +**Usage:** +``` +/cancel-ralph +``` + +**How it works:** +- Checks for active loop state file +- Removes `.claude/.ralph-loop.local.md` +- Reports cancellation with iteration count + +--- + +## Key Concepts + +### Completion Promises + +To signal completion, Claude must output a `` tag: + +``` +TASK COMPLETE +``` + +The stop hook looks for this specific tag. Without it (or `--max-iterations`), Ralph runs infinitely. + +### Self-Reference Mechanism + +The "loop" doesn't mean Claude talks to itself. It means: +- Same prompt repeated +- Claude's work persists in files +- Each iteration sees previous attempts +- Builds incrementally toward goal + +## Example + +### Interactive Bug Fix + +``` +/ralph-loop "Fix the token refresh logic in auth.ts. Output FIXED when all tests pass." --completion-promise "FIXED" --max-iterations 10 +``` + +You'll see Ralph: +- Attempt fixes +- Run tests +- See failures +- Iterate on solution +- In your current session + +## When to Use Ralph + +**Good for:** +- Well-defined tasks with clear success criteria +- Tasks requiring iteration and refinement +- Iterative development with self-correction +- Greenfield projects + +**Not good for:** +- Tasks requiring human judgment or design decisions +- One-shot operations +- Tasks with unclear success criteria +- Debugging production issues (use targeted debugging instead) + +## Learn More + +- Original technique: https://ghuntley.com/ralph/ +- Ralph Orchestrator: https://github.com/mikeyobrien/ralph-orchestrator diff --git a/plugins/ralph-wiggum/commands/ralph-loop.md b/plugins/ralph-wiggum/commands/ralph-loop.md new file mode 100644 index 00000000..e54de24d --- /dev/null +++ b/plugins/ralph-wiggum/commands/ralph-loop.md @@ -0,0 +1,48 @@ +--- +description: "Start Ralph Wiggum loop in current session" +argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]" +allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh)"] +hide-from-slash-command-tool: "true" +--- + +# Ralph Loop Command + +Execute the setup script to initialize the Ralph loop: + +```! +"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS + +# Extract and display completion promise if set +if [ -f .claude/ralph-loop.local.md ]; then + PROMISE=$(grep '^completion_promise:' .claude/ralph-loop.local.md | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/') + if [ -n "$PROMISE" ] && [ "$PROMISE" != "null" ]; then + echo "" + echo "═══════════════════════════════════════════════════════════" + echo "CRITICAL - Ralph Loop Completion Promise" + echo "═══════════════════════════════════════════════════════════" + echo "" + echo "To complete this loop, output this EXACT text:" + echo " $PROMISE" + echo "" + echo "STRICT REQUIREMENTS (DO NOT VIOLATE):" + echo " ✓ Use XML tags EXACTLY as shown above" + echo " ✓ The statement MUST be completely and unequivocally TRUE" + echo " ✓ Do NOT output false statements to exit the loop" + echo " ✓ Do NOT lie even if you think you should exit" + echo "" + echo "IMPORTANT - Do not circumvent the loop:" + echo " Even if you believe you're stuck, the task is impossible," + echo " or you've been running too long - you MUST NOT output a" + echo " false promise statement. The loop is designed to continue" + echo " until the promise is GENUINELY TRUE. Trust the process." + echo "" + echo " If the loop should stop, the promise statement will become" + echo " true naturally. Do not force it by lying." + echo "═══════════════════════════════════════════════════════════" + fi +fi +``` + +Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve. + +CRITICAL RULE: If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop, even if you think you're stuck or should exit for other reasons. The loop is designed to continue until genuine completion. diff --git a/plugins/ralph-wiggum/hooks/hooks.json b/plugins/ralph-wiggum/hooks/hooks.json new file mode 100644 index 00000000..2e5f6979 --- /dev/null +++ b/plugins/ralph-wiggum/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "description": "Ralph Wiggum plugin stop hook for self-referential loops", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh" + } + ] + } + ] + } +} diff --git a/plugins/ralph-wiggum/hooks/stop-hook.sh b/plugins/ralph-wiggum/hooks/stop-hook.sh new file mode 100755 index 00000000..9aa611c1 --- /dev/null +++ b/plugins/ralph-wiggum/hooks/stop-hook.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +# Ralph Wiggum Stop Hook +# Prevents session exit when a ralph-loop is active +# Feeds Claude's output back as input to continue the loop + +set -euo pipefail + +# Read hook input from stdin (advanced stop hook API) +HOOK_INPUT=$(cat) + +# Check if ralph-loop is active +RALPH_STATE_FILE=".claude/ralph-loop.local.md" + +if [[ ! -f "$RALPH_STATE_FILE" ]]; then + # No active loop - allow exit + exit 0 +fi + +# Parse markdown frontmatter (YAML between ---) and extract values +FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$RALPH_STATE_FILE") +ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//') +MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//') +# Extract completion_promise and strip surrounding quotes if present +COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/') + +# Validate numeric fields before arithmetic operations +if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then + echo "⚠️ Ralph loop: State file corrupted" >&2 + echo " File: $RALPH_STATE_FILE" >&2 + echo " Problem: 'iteration' field is not a valid number (got: '$ITERATION')" >&2 + echo "" >&2 + echo " This usually means the state file was manually edited or corrupted." >&2 + echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then + echo "⚠️ Ralph loop: State file corrupted" >&2 + echo " File: $RALPH_STATE_FILE" >&2 + echo " Problem: 'max_iterations' field is not a valid number (got: '$MAX_ITERATIONS')" >&2 + echo "" >&2 + echo " This usually means the state file was manually edited or corrupted." >&2 + echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Check if max iterations reached +if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then + echo "🛑 Ralph loop: Max iterations ($MAX_ITERATIONS) reached." + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Get transcript path from hook input +TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path') + +if [[ ! -f "$TRANSCRIPT_PATH" ]]; then + echo "⚠️ Ralph loop: Transcript file not found" >&2 + echo " Expected: $TRANSCRIPT_PATH" >&2 + echo " This is unusual and may indicate a Claude Code internal issue." >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Read last assistant message from transcript (JSONL format - one JSON per line) +# First check if there are any assistant messages +if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then + echo "⚠️ Ralph loop: No assistant messages found in transcript" >&2 + echo " Transcript: $TRANSCRIPT_PATH" >&2 + echo " This is unusual and may indicate a transcript format issue" >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Extract last assistant message with explicit error handling +LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -1) +if [[ -z "$LAST_LINE" ]]; then + echo "⚠️ Ralph loop: Failed to extract last assistant message" >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Parse JSON with error handling +LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r ' + .message.content | + map(select(.type == "text")) | + map(.text) | + join("\n") +' 2>&1) + +# Check if jq succeeded +if [[ $? -ne 0 ]]; then + echo "⚠️ Ralph loop: Failed to parse assistant message JSON" >&2 + echo " Error: $LAST_OUTPUT" >&2 + echo " This may indicate a transcript format issue" >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +if [[ -z "$LAST_OUTPUT" ]]; then + echo "⚠️ Ralph loop: Assistant message contained no text content" >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Check for completion promise (only if set) +if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then + # Extract text from tags using Perl for multiline support + # -0777 slurps entire input, s flag makes . match newlines + # .*? is non-greedy (takes FIRST tag), whitespace normalized + PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "") + + # Use = for literal string comparison (not pattern matching) + # == in [[ ]] does glob pattern matching which breaks with *, ?, [ characters + if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then + echo "✅ Ralph loop: Detected $COMPLETION_PROMISE" + rm "$RALPH_STATE_FILE" + exit 0 + fi +fi + +# Not complete - continue loop with SAME PROMPT +NEXT_ITERATION=$((ITERATION + 1)) + +# Extract prompt (everything after the closing ---) +# Skip first --- line, skip until second --- line, then print everything after +# Use i>=2 instead of i==2 to handle --- in prompt content +PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE_FILE") + +if [[ -z "$PROMPT_TEXT" ]]; then + echo "⚠️ Ralph loop: State file corrupted or incomplete" >&2 + echo " File: $RALPH_STATE_FILE" >&2 + echo " Problem: No prompt text found" >&2 + echo "" >&2 + echo " This usually means:" >&2 + echo " • State file was manually edited" >&2 + echo " • File was corrupted during writing" >&2 + echo "" >&2 + echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Update iteration in frontmatter (portable across macOS and Linux) +# Create temp file, then atomically replace +TEMP_FILE="${RALPH_STATE_FILE}.tmp.$$" +sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$RALPH_STATE_FILE" > "$TEMP_FILE" +mv "$TEMP_FILE" "$RALPH_STATE_FILE" + +# Build system message with iteration count and completion promise info +if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then + SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | To stop: output $COMPLETION_PROMISE (ONLY when statement is TRUE - do not lie to exit!)" +else + SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | No completion promise set - loop runs infinitely" +fi + +# Output JSON to block the stop and feed prompt back +# The "reason" field contains the prompt that will be sent back to Claude +jq -n \ + --arg prompt "$PROMPT_TEXT" \ + --arg msg "$SYSTEM_MSG" \ + '{ + "decision": "block", + "reason": $prompt, + "systemMessage": $msg + }' + +# Exit 0 for successful hook execution +exit 0 diff --git a/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh b/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh new file mode 100755 index 00000000..7e334f4b --- /dev/null +++ b/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh @@ -0,0 +1,176 @@ +#!/bin/bash + +# Ralph Loop Setup Script +# Creates state file for in-session Ralph loop + +set -euo pipefail + +# Parse arguments +PROMPT_PARTS=() +MAX_ITERATIONS=0 +COMPLETION_PROMISE="null" + +# Parse options and positional arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + cat << 'HELP_EOF' +Ralph Loop - Interactive self-referential development loop + +USAGE: + /ralph-loop [PROMPT...] [OPTIONS] + +ARGUMENTS: + PROMPT... Initial prompt to start the loop (can be multiple words without quotes) + +OPTIONS: + --max-iterations Maximum iterations before auto-stop (default: unlimited) + --completion-promise '' Promise phrase (USE QUOTES for multi-word) + -h, --help Show this help message + +DESCRIPTION: + Starts a Ralph Wiggum loop in your CURRENT session. The stop hook prevents + exit and feeds your output back as input until completion or iteration limit. + + To signal completion, you must output: YOUR_PHRASE + + Use this for: + - Interactive iteration where you want to see progress + - Tasks requiring self-correction and refinement + - Learning how Ralph works + +EXAMPLES: + /ralph-loop Build a todo API --completion-promise 'DONE' --max-iterations 20 + /ralph-loop --max-iterations 10 Fix the auth bug + /ralph-loop Refactor cache layer (runs forever) + /ralph-loop --completion-promise 'TASK COMPLETE' Create a REST API + +STOPPING: + Only by reaching --max-iterations or detecting --completion-promise + No manual stop - Ralph runs infinitely by default! + +MONITORING: + # View current iteration: + grep '^iteration:' .claude/ralph-loop.local.md + + # View full state: + head -10 .claude/ralph-loop.local.md +HELP_EOF + exit 0 + ;; + --max-iterations) + if [[ -z "${2:-}" ]]; then + echo "❌ Error: --max-iterations requires a number argument" >&2 + echo "" >&2 + echo " Valid examples:" >&2 + echo " --max-iterations 10" >&2 + echo " --max-iterations 50" >&2 + echo " --max-iterations 0 (unlimited)" >&2 + echo "" >&2 + echo " You provided: --max-iterations (with no number)" >&2 + exit 1 + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + echo "❌ Error: --max-iterations must be a positive integer or 0, got: $2" >&2 + echo "" >&2 + echo " Valid examples:" >&2 + echo " --max-iterations 10" >&2 + echo " --max-iterations 50" >&2 + echo " --max-iterations 0 (unlimited)" >&2 + echo "" >&2 + echo " Invalid: decimals (10.5), negative numbers (-5), text" >&2 + exit 1 + fi + MAX_ITERATIONS="$2" + shift 2 + ;; + --completion-promise) + if [[ -z "${2:-}" ]]; then + echo "❌ Error: --completion-promise requires a text argument" >&2 + echo "" >&2 + echo " Valid examples:" >&2 + echo " --completion-promise 'DONE'" >&2 + echo " --completion-promise 'TASK COMPLETE'" >&2 + echo " --completion-promise 'All tests passing'" >&2 + echo "" >&2 + echo " You provided: --completion-promise (with no text)" >&2 + echo "" >&2 + echo " Note: Multi-word promises must be quoted!" >&2 + exit 1 + fi + COMPLETION_PROMISE="$2" + shift 2 + ;; + *) + # Non-option argument - collect all as prompt parts + PROMPT_PARTS+=("$1") + shift + ;; + esac +done + +# Join all prompt parts with spaces +PROMPT="${PROMPT_PARTS[*]}" + +# Validate prompt is non-empty +if [[ -z "$PROMPT" ]]; then + echo "❌ Error: No prompt provided" >&2 + echo "" >&2 + echo " Ralph needs a task description to work on." >&2 + echo "" >&2 + echo " Examples:" >&2 + echo " /ralph-loop Build a REST API for todos" >&2 + echo " /ralph-loop Fix the auth bug --max-iterations 20" >&2 + echo " /ralph-loop --completion-promise 'DONE' Refactor code" >&2 + echo "" >&2 + echo " For all options: /ralph-loop --help" >&2 + exit 1 +fi + +# Create state file for stop hook (markdown with YAML frontmatter) +mkdir -p .claude + +# Quote completion promise for YAML if it contains special chars or is not null +if [[ -n "$COMPLETION_PROMISE" ]] && [[ "$COMPLETION_PROMISE" != "null" ]]; then + COMPLETION_PROMISE_YAML="\"$COMPLETION_PROMISE\"" +else + COMPLETION_PROMISE_YAML="null" +fi + +cat > .claude/ralph-loop.local.md < Date: Mon, 17 Nov 2025 03:18:43 -0800 Subject: [PATCH 2/2] feat: Add hookify plugin for custom hook rules via markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the hookify plugin to public marketplace. Enables users to create custom hooks using simple markdown configuration files instead of editing JSON. Key features: - Define rules with regex patterns to warn/block operations - Create rules from explicit instructions or conversation analysis - Pattern-based matching for bash commands, file edits, prompts, stop events - Enable/disable rules dynamically without editing code - Conversation analyzer agent finds problematic behaviors Changes from internal version: - Removed non-functional SessionStart hook (not registered in hooks.json) - Removed all sessionstart documentation and examples - Fixed restart documentation to consistently state "no restart needed" - Changed license from "Internal Anthropic use only" to "MIT License" - Kept test blocks in core modules (useful for developers) Plugin provides: - 4 commands: /hookify, /hookify:list, /hookify:configure, /hookify:help - 1 agent: conversation-analyzer - 1 skill: writing-rules - 4 hook types: PreToolUse, PostToolUse, Stop, UserPromptSubmit - 4 example rules ready to use All features functional and suitable for public use. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 11 + plugins/hookify/.claude-plugin/plugin.json | 9 + plugins/hookify/.gitignore | 30 ++ plugins/hookify/README.md | 340 ++++++++++++++++ .../hookify/agents/conversation-analyzer.md | 176 +++++++++ plugins/hookify/commands/configure.md | 128 ++++++ plugins/hookify/commands/help.md | 175 ++++++++ plugins/hookify/commands/hookify.md | 231 +++++++++++ plugins/hookify/commands/list.md | 82 ++++ plugins/hookify/core/__init__.py | 0 plugins/hookify/core/config_loader.py | 297 ++++++++++++++ plugins/hookify/core/rule_engine.py | 313 +++++++++++++++ .../examples/console-log-warning.local.md | 14 + .../hookify/examples/dangerous-rm.local.md | 14 + .../examples/require-tests-stop.local.md | 22 ++ .../examples/sensitive-files-warning.local.md | 18 + plugins/hookify/hooks/__init__.py | 0 plugins/hookify/hooks/hooks.json | 49 +++ plugins/hookify/hooks/posttooluse.py | 66 ++++ plugins/hookify/hooks/pretooluse.py | 74 ++++ plugins/hookify/hooks/stop.py | 59 +++ plugins/hookify/hooks/userpromptsubmit.py | 58 +++ plugins/hookify/matchers/__init__.py | 0 plugins/hookify/skills/writing-rules/SKILL.md | 374 ++++++++++++++++++ plugins/hookify/utils/__init__.py | 0 25 files changed, 2540 insertions(+) create mode 100644 plugins/hookify/.claude-plugin/plugin.json create mode 100644 plugins/hookify/.gitignore create mode 100644 plugins/hookify/README.md create mode 100644 plugins/hookify/agents/conversation-analyzer.md create mode 100644 plugins/hookify/commands/configure.md create mode 100644 plugins/hookify/commands/help.md create mode 100644 plugins/hookify/commands/hookify.md create mode 100644 plugins/hookify/commands/list.md create mode 100644 plugins/hookify/core/__init__.py create mode 100644 plugins/hookify/core/config_loader.py create mode 100644 plugins/hookify/core/rule_engine.py create mode 100644 plugins/hookify/examples/console-log-warning.local.md create mode 100644 plugins/hookify/examples/dangerous-rm.local.md create mode 100644 plugins/hookify/examples/require-tests-stop.local.md create mode 100644 plugins/hookify/examples/sensitive-files-warning.local.md create mode 100755 plugins/hookify/hooks/__init__.py create mode 100644 plugins/hookify/hooks/hooks.json create mode 100755 plugins/hookify/hooks/posttooluse.py create mode 100755 plugins/hookify/hooks/pretooluse.py create mode 100755 plugins/hookify/hooks/stop.py create mode 100755 plugins/hookify/hooks/userpromptsubmit.py create mode 100644 plugins/hookify/matchers/__init__.py create mode 100644 plugins/hookify/skills/writing-rules/SKILL.md create mode 100644 plugins/hookify/utils/__init__.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a99123f6..5b88dab1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -112,6 +112,17 @@ }, "source": "./plugins/ralph-wiggum", "category": "development" + }, + { + "name": "hookify", + "description": "Easily create custom hooks to prevent unwanted behaviors by analyzing conversation patterns or from explicit instructions. Define rules via simple markdown files.", + "version": "0.1.0", + "author": { + "name": "Daisy Hollman", + "email": "daisy@anthropic.com" + }, + "source": "./plugins/hookify", + "category": "productivity" } ] } diff --git a/plugins/hookify/.claude-plugin/plugin.json b/plugins/hookify/.claude-plugin/plugin.json new file mode 100644 index 00000000..3cf8b5b8 --- /dev/null +++ b/plugins/hookify/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "hookify", + "version": "0.1.0", + "description": "Easily create hooks to prevent unwanted behaviors by analyzing conversation patterns", + "author": { + "name": "Daisy Hollman", + "email": "daisy@anthropic.com" + } +} diff --git a/plugins/hookify/.gitignore b/plugins/hookify/.gitignore new file mode 100644 index 00000000..6d5f8af5 --- /dev/null +++ b/plugins/hookify/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Local configuration (should not be committed) +.claude/*.local.md +.claude/*.local.json diff --git a/plugins/hookify/README.md b/plugins/hookify/README.md new file mode 100644 index 00000000..1aca6cdf --- /dev/null +++ b/plugins/hookify/README.md @@ -0,0 +1,340 @@ +# Hookify Plugin + +Easily create custom hooks to prevent unwanted behaviors by analyzing conversation patterns or from explicit instructions. + +## Overview + +The hookify plugin makes it simple to create hooks without editing complex `hooks.json` files. Instead, you create lightweight markdown configuration files that define patterns to watch for and messages to show when those patterns match. + +**Key features:** +- 🎯 Analyze conversations to find unwanted behaviors automatically +- 📝 Simple markdown configuration files with YAML frontmatter +- 🔍 Regex pattern matching for powerful rules +- 🚀 No coding required - just describe the behavior +- 🔄 Easy enable/disable without restarting + +## Quick Start + +### 1. Create Your First Rule + +```bash +/hookify Warn me when I use rm -rf commands +``` + +This analyzes your request and creates `.claude/hookify.warn-rm.local.md`. + +### 2. Test It Immediately + +**No restart needed!** Rules take effect on the very next tool use. + +Ask Claude to run a command that should trigger the rule: +``` +Run rm -rf /tmp/test +``` + +You should see the warning message immediately! + +## Usage + +### Main Command: /hookify + +**With arguments:** +``` +/hookify Don't use console.log in TypeScript files +``` +Creates a rule from your explicit instructions. + +**Without arguments:** +``` +/hookify +``` +Analyzes recent conversation to find behaviors you've corrected or been frustrated by. + +### Helper Commands + +**List all rules:** +``` +/hookify:list +``` + +**Configure rules interactively:** +``` +/hookify:configure +``` +Enable/disable existing rules through an interactive interface. + +**Get help:** +``` +/hookify:help +``` + +## Rule Configuration Format + +### Simple Rule (Single Pattern) + +`.claude/hookify.dangerous-rm.local.md`: +```markdown +--- +name: block-dangerous-rm +enabled: true +event: bash +pattern: rm\s+-rf +action: block +--- + +⚠️ **Dangerous rm command detected!** + +This command could delete important files. Please: +- Verify the path is correct +- Consider using a safer approach +- Make sure you have backups +``` + +**Action field:** +- `warn`: Shows warning but allows operation (default) +- `block`: Prevents operation from executing (PreToolUse) or stops session (Stop events) + +### Advanced Rule (Multiple Conditions) + +`.claude/hookify.sensitive-files.local.md`: +```markdown +--- +name: warn-sensitive-files +enabled: true +event: file +action: warn +conditions: + - field: file_path + operator: regex_match + pattern: \.env$|credentials|secrets + - field: new_text + operator: contains + pattern: KEY +--- + +🔐 **Sensitive file edit detected!** + +Ensure credentials are not hardcoded and file is in .gitignore. +``` + +**All conditions must match** for the rule to trigger. + +## Event Types + +- **`bash`**: Triggers on Bash tool commands +- **`file`**: Triggers on Edit, Write, MultiEdit tools +- **`stop`**: Triggers when Claude wants to stop (for completion checks) +- **`prompt`**: Triggers on user prompt submission +- **`all`**: Triggers on all events + +## Pattern Syntax + +Use Python regex syntax: + +| Pattern | Matches | Example | +|---------|---------|---------| +| `rm\s+-rf` | rm -rf | rm -rf /tmp | +| `console\.log\(` | console.log( | console.log("test") | +| `(eval\|exec)\(` | eval( or exec( | eval("code") | +| `\.env$` | files ending in .env | .env, .env.local | +| `chmod\s+777` | chmod 777 | chmod 777 file.txt | + +**Tips:** +- Use `\s` for whitespace +- Escape special chars: `\.` for literal dot +- Use `|` for OR: `(foo|bar)` +- Use `.*` to match anything +- Set `action: block` for dangerous operations +- Set `action: warn` (or omit) for informational warnings + +## Examples + +### Example 1: Block Dangerous Commands + +```markdown +--- +name: block-destructive-ops +enabled: true +event: bash +pattern: rm\s+-rf|dd\s+if=|mkfs|format +action: block +--- + +🛑 **Destructive operation detected!** + +This command can cause data loss. Operation blocked for safety. +Please verify the exact path and use a safer approach. +``` + +**This rule blocks the operation** - Claude will not be allowed to execute these commands. + +### Example 2: Warn About Debug Code + +```markdown +--- +name: warn-debug-code +enabled: true +event: file +pattern: console\.log\(|debugger;|print\( +action: warn +--- + +🐛 **Debug code detected** + +Remember to remove debugging statements before committing. +``` + +**This rule warns but allows** - Claude sees the message but can still proceed. + +### Example 3: Require Tests Before Stopping + +```markdown +--- +name: require-tests-run +enabled: false +event: stop +action: block +conditions: + - field: transcript + operator: not_contains + pattern: npm test|pytest|cargo test +--- + +**Tests not detected in transcript!** + +Before stopping, please run tests to verify your changes work correctly. +``` + +**This blocks Claude from stopping** if no test commands appear in the session transcript. Enable only when you want strict enforcement. + +## Advanced Usage + +### Multiple Conditions + +Check multiple fields simultaneously: + +```markdown +--- +name: api-key-in-typescript +enabled: true +event: file +conditions: + - field: file_path + operator: regex_match + pattern: \.tsx?$ + - field: new_text + operator: regex_match + pattern: (API_KEY|SECRET|TOKEN)\s*=\s*["'] +--- + +🔐 **Hardcoded credential in TypeScript!** + +Use environment variables instead of hardcoded values. +``` + +### Operators Reference + +- `regex_match`: Pattern must match (most common) +- `contains`: String must contain pattern +- `equals`: Exact string match +- `not_contains`: String must NOT contain pattern +- `starts_with`: String starts with pattern +- `ends_with`: String ends with pattern + +### Field Reference + +**For bash events:** +- `command`: The bash command string + +**For file events:** +- `file_path`: Path to file being edited +- `new_text`: New content being added (Edit, Write) +- `old_text`: Old content being replaced (Edit only) +- `content`: File content (Write only) + +**For prompt events:** +- `user_prompt`: The user's submitted prompt text + +**For stop events:** +- Use general matching on session state + +## Management + +### Enable/Disable Rules + +**Temporarily disable:** +Edit the `.local.md` file and set `enabled: false` + +**Re-enable:** +Set `enabled: true` + +**Or use interactive tool:** +``` +/hookify:configure +``` + +### Delete Rules + +Simply delete the `.local.md` file: +```bash +rm .claude/hookify.my-rule.local.md +``` + +### View All Rules + +``` +/hookify:list +``` + +## Installation + +This plugin is part of the Claude Code Marketplace. It should be auto-discovered when the marketplace is installed. + +**Manual testing:** +```bash +cc --plugin-dir /path/to/hookify +``` + +## Requirements + +- Python 3.7+ +- No external dependencies (uses stdlib only) + +## Troubleshooting + +**Rule not triggering:** +1. Check rule file exists in `.claude/` directory (in project root, not plugin directory) +2. Verify `enabled: true` in frontmatter +3. Test regex pattern separately +4. Rules should work immediately - no restart needed +5. Try `/hookify:list` to see if rule is loaded + +**Import errors:** +- Ensure Python 3 is available: `python3 --version` +- Check hookify plugin is installed + +**Pattern not matching:** +- Test regex: `python3 -c "import re; print(re.search(r'pattern', 'text'))"` +- Use unquoted patterns in YAML to avoid escaping issues +- Start simple, then add complexity + +**Hook seems slow:** +- Keep patterns simple (avoid complex regex) +- Use specific event types (bash, file) instead of "all" +- Limit number of active rules + +## Contributing + +Found a useful rule pattern? Consider sharing example files via PR! + +## Future Enhancements + +- Severity levels (error/warning/info distinctions) +- Rule templates library +- Interactive pattern builder +- Hook testing utilities +- JSON format support (in addition to markdown) + +## License + +MIT License diff --git a/plugins/hookify/agents/conversation-analyzer.md b/plugins/hookify/agents/conversation-analyzer.md new file mode 100644 index 00000000..cb91a41a --- /dev/null +++ b/plugins/hookify/agents/conversation-analyzer.md @@ -0,0 +1,176 @@ +--- +name: conversation-analyzer +description: Use this agent when analyzing conversation transcripts to find behaviors worth preventing with hooks. Examples: Context: User is running /hookify command without arguments\nuser: "/hookify"\nassistant: "I'll analyze the conversation to find behaviors you want to prevent"\nThe /hookify command without arguments triggers conversation analysis to find unwanted behaviors.Context: User wants to create hooks from recent frustrations\nuser: "Can you look back at this conversation and help me create hooks for the mistakes you made?"\nassistant: "I'll use the conversation-analyzer agent to identify the issues and suggest hooks."\nUser explicitly asks to analyze conversation for mistakes that should be prevented. +model: inherit +color: yellow +tools: ["Read", "Grep"] +--- + +You are a conversation analysis specialist that identifies problematic behaviors in Claude Code sessions that could be prevented with hooks. + +**Your Core Responsibilities:** +1. Read and analyze user messages to find frustration signals +2. Identify specific tool usage patterns that caused issues +3. Extract actionable patterns that can be matched with regex +4. Categorize issues by severity and type +5. Provide structured findings for hook rule generation + +**Analysis Process:** + +### 1. Search for User Messages Indicating Issues + +Read through user messages in reverse chronological order (most recent first). Look for: + +**Explicit correction requests:** +- "Don't use X" +- "Stop doing Y" +- "Please don't Z" +- "Avoid..." +- "Never..." + +**Frustrated reactions:** +- "Why did you do X?" +- "I didn't ask for that" +- "That's not what I meant" +- "That was wrong" + +**Corrections and reversions:** +- User reverting changes Claude made +- User fixing issues Claude created +- User providing step-by-step corrections + +**Repeated issues:** +- Same type of mistake multiple times +- User having to remind multiple times +- Pattern of similar problems + +### 2. Identify Tool Usage Patterns + +For each issue, determine: +- **Which tool**: Bash, Edit, Write, MultiEdit +- **What action**: Specific command or code pattern +- **When it happened**: During what task/phase +- **Why problematic**: User's stated reason or implicit concern + +**Extract concrete examples:** +- For Bash: Actual command that was problematic +- For Edit/Write: Code pattern that was added +- For Stop: What was missing before stopping + +### 3. Create Regex Patterns + +Convert behaviors into matchable patterns: + +**Bash command patterns:** +- `rm\s+-rf` for dangerous deletes +- `sudo\s+` for privilege escalation +- `chmod\s+777` for permission issues + +**Code patterns (Edit/Write):** +- `console\.log\(` for debug logging +- `eval\(|new Function\(` for dangerous eval +- `innerHTML\s*=` for XSS risks + +**File path patterns:** +- `\.env$` for environment files +- `/node_modules/` for dependency files +- `dist/|build/` for generated files + +### 4. Categorize Severity + +**High severity (should block in future):** +- Dangerous commands (rm -rf, chmod 777) +- Security issues (hardcoded secrets, eval) +- Data loss risks + +**Medium severity (warn):** +- Style violations (console.log in production) +- Wrong file types (editing generated files) +- Missing best practices + +**Low severity (optional):** +- Preferences (coding style) +- Non-critical patterns + +### 5. Output Format + +Return your findings as structured text in this format: + +``` +## Hookify Analysis Results + +### Issue 1: Dangerous rm Commands +**Severity**: High +**Tool**: Bash +**Pattern**: `rm\s+-rf` +**Occurrences**: 3 times +**Context**: Used rm -rf on /tmp directories without verification +**User Reaction**: "Please be more careful with rm commands" + +**Suggested Rule:** +- Name: warn-dangerous-rm +- Event: bash +- Pattern: rm\s+-rf +- Message: "Dangerous rm command detected. Verify path before proceeding." + +--- + +### Issue 2: Console.log in TypeScript +**Severity**: Medium +**Tool**: Edit/Write +**Pattern**: `console\.log\(` +**Occurrences**: 2 times +**Context**: Added console.log statements to production TypeScript files +**User Reaction**: "Don't use console.log in production code" + +**Suggested Rule:** +- Name: warn-console-log +- Event: file +- Pattern: console\.log\( +- Message: "Console.log detected. Use proper logging library instead." + +--- + +[Continue for each issue found...] + +## Summary + +Found {N} behaviors worth preventing: +- {N} high severity +- {N} medium severity +- {N} low severity + +Recommend creating rules for high and medium severity issues. +``` + +**Quality Standards:** +- Be specific about patterns (don't be overly broad) +- Include actual examples from conversation +- Explain why each issue matters +- Provide ready-to-use regex patterns +- Don't false-positive on discussions about what NOT to do + +**Edge Cases:** + +**User discussing hypotheticals:** +- "What would happen if I used rm -rf?" +- Don't treat as problematic behavior + +**Teaching moments:** +- "Here's what you shouldn't do: ..." +- Context indicates explanation, not actual problem + +**One-time accidents:** +- Single occurrence, already fixed +- Mention but mark as low priority + +**Subjective preferences:** +- "I prefer X over Y" +- Mark as low severity, let user decide + +**Return Results:** +Provide your analysis in the structured format above. The /hookify command will use this to: +1. Present findings to user +2. Ask which rules to create +3. Generate .local.md configuration files +4. Save rules to .claude directory diff --git a/plugins/hookify/commands/configure.md b/plugins/hookify/commands/configure.md new file mode 100644 index 00000000..ccc7e472 --- /dev/null +++ b/plugins/hookify/commands/configure.md @@ -0,0 +1,128 @@ +--- +description: Enable or disable hookify rules interactively +allowed-tools: ["Glob", "Read", "Edit", "AskUserQuestion", "Skill"] +--- + +# Configure Hookify Rules + +**Load hookify:writing-rules skill first** to understand rule format. + +Enable or disable existing hookify rules using an interactive interface. + +## Steps + +### 1. Find Existing Rules + +Use Glob tool to find all hookify rule files: +``` +pattern: ".claude/hookify.*.local.md" +``` + +If no rules found, inform user: +``` +No hookify rules configured yet. Use `/hookify` to create your first rule. +``` + +### 2. Read Current State + +For each rule file: +- Read the file +- Extract `name` and `enabled` fields from frontmatter +- Build list of rules with current state + +### 3. Ask User Which Rules to Toggle + +Use AskUserQuestion to let user select rules: + +```json +{ + "questions": [ + { + "question": "Which rules would you like to enable or disable?", + "header": "Configure", + "multiSelect": true, + "options": [ + { + "label": "warn-dangerous-rm (currently enabled)", + "description": "Warns about rm -rf commands" + }, + { + "label": "warn-console-log (currently disabled)", + "description": "Warns about console.log in code" + }, + { + "label": "require-tests (currently enabled)", + "description": "Requires tests before stopping" + } + ] + } + ] +} +``` + +**Option format:** +- Label: `{rule-name} (currently {enabled|disabled})` +- Description: Brief description from rule's message or pattern + +### 4. Parse User Selection + +For each selected rule: +- Determine current state from label (enabled/disabled) +- Toggle state: enabled → disabled, disabled → enabled + +### 5. Update Rule Files + +For each rule to toggle: +- Use Read tool to read current content +- Use Edit tool to change `enabled: true` to `enabled: false` (or vice versa) +- Handle both with and without quotes + +**Edit pattern for enabling:** +``` +old_string: "enabled: false" +new_string: "enabled: true" +``` + +**Edit pattern for disabling:** +``` +old_string: "enabled: true" +new_string: "enabled: false" +``` + +### 6. Confirm Changes + +Show user what was changed: + +``` +## Hookify Rules Updated + +**Enabled:** +- warn-console-log + +**Disabled:** +- warn-dangerous-rm + +**Unchanged:** +- require-tests + +Changes apply immediately - no restart needed +``` + +## Important Notes + +- Changes take effect immediately on next tool use +- You can also manually edit .claude/hookify.*.local.md files +- To permanently remove a rule, delete its .local.md file +- Use `/hookify:list` to see all configured rules + +## Edge Cases + +**No rules to configure:** +- Show message about using `/hookify` to create rules first + +**User selects no rules:** +- Inform that no changes were made + +**File read/write errors:** +- Inform user of specific error +- Suggest manual editing as fallback diff --git a/plugins/hookify/commands/help.md b/plugins/hookify/commands/help.md new file mode 100644 index 00000000..ae6e94b1 --- /dev/null +++ b/plugins/hookify/commands/help.md @@ -0,0 +1,175 @@ +--- +description: Get help with the hookify plugin +allowed-tools: ["Read"] +--- + +# Hookify Plugin Help + +Explain how the hookify plugin works and how to use it. + +## Overview + +The hookify plugin makes it easy to create custom hooks that prevent unwanted behaviors. Instead of editing `hooks.json` files, users create simple markdown configuration files that define patterns to watch for. + +## How It Works + +### 1. Hook System + +Hookify installs generic hooks that run on these events: +- **PreToolUse**: Before any tool executes (Bash, Edit, Write, etc.) +- **PostToolUse**: After a tool executes +- **Stop**: When Claude wants to stop working +- **UserPromptSubmit**: When user submits a prompt + +These hooks read configuration files from `.claude/hookify.*.local.md` and check if any rules match the current operation. + +### 2. Configuration Files + +Users create rules in `.claude/hookify.{rule-name}.local.md` files: + +```markdown +--- +name: warn-dangerous-rm +enabled: true +event: bash +pattern: rm\s+-rf +--- + +⚠️ **Dangerous rm command detected!** + +This command could delete important files. Please verify the path. +``` + +**Key fields:** +- `name`: Unique identifier for the rule +- `enabled`: true/false to activate/deactivate +- `event`: bash, file, stop, prompt, or all +- `pattern`: Regex pattern to match + +The message body is what Claude sees when the rule triggers. + +### 3. Creating Rules + +**Option A: Use /hookify command** +``` +/hookify Don't use console.log in production files +``` + +This analyzes your request and creates the appropriate rule file. + +**Option B: Create manually** +Create `.claude/hookify.my-rule.local.md` with the format above. + +**Option C: Analyze conversation** +``` +/hookify +``` + +Without arguments, hookify analyzes recent conversation to find behaviors you want to prevent. + +## Available Commands + +- **`/hookify`** - Create hooks from conversation analysis or explicit instructions +- **`/hookify:help`** - Show this help (what you're reading now) +- **`/hookify:list`** - List all configured hooks +- **`/hookify:configure`** - Enable/disable existing hooks interactively + +## Example Use Cases + +**Prevent dangerous commands:** +```markdown +--- +name: block-chmod-777 +enabled: true +event: bash +pattern: chmod\s+777 +--- + +Don't use chmod 777 - it's a security risk. Use specific permissions instead. +``` + +**Warn about debugging code:** +```markdown +--- +name: warn-console-log +enabled: true +event: file +pattern: console\.log\( +--- + +Console.log detected. Remember to remove debug logging before committing. +``` + +**Require tests before stopping:** +```markdown +--- +name: require-tests +enabled: true +event: stop +pattern: .* +--- + +Did you run tests before finishing? Make sure `npm test` or equivalent was executed. +``` + +## Pattern Syntax + +Use Python regex syntax: +- `\s` - whitespace +- `\.` - literal dot +- `|` - OR +- `+` - one or more +- `*` - zero or more +- `\d` - digit +- `[abc]` - character class + +**Examples:** +- `rm\s+-rf` - matches "rm -rf" +- `console\.log\(` - matches "console.log(" +- `(eval|exec)\(` - matches "eval(" or "exec(" +- `\.env$` - matches files ending in .env + +## Important Notes + +**No Restart Needed**: Hookify rules (`.local.md` files) take effect immediately on the next tool use. The hookify hooks are already loaded and read your rules dynamically. + +**Block or Warn**: Rules can either `block` operations (prevent execution) or `warn` (show message but allow). Set `action: block` or `action: warn` in the rule's frontmatter. + +**Rule Files**: Keep rules in `.claude/hookify.*.local.md` - they should be git-ignored (add to .gitignore if needed). + +**Disable Rules**: Set `enabled: false` in frontmatter or delete the file. + +## Troubleshooting + +**Hook not triggering:** +- Check rule file is in `.claude/` directory +- Verify `enabled: true` in frontmatter +- Confirm pattern is valid regex +- Test pattern: `python3 -c "import re; print(re.search('your_pattern', 'test_text'))"` +- Rules take effect immediately - no restart needed + +**Import errors:** +- Check Python 3 is available: `python3 --version` +- Verify hookify plugin is installed correctly + +**Pattern not matching:** +- Test regex separately +- Check for escaping issues (use unquoted patterns in YAML) +- Try simpler pattern first, then refine + +## Getting Started + +1. Create your first rule: + ``` + /hookify Warn me when I try to use rm -rf + ``` + +2. Try to trigger it: + - Ask Claude to run `rm -rf /tmp/test` + - You should see the warning + +4. Refine the rule by editing `.claude/hookify.warn-rm.local.md` + +5. Create more rules as you encounter unwanted behaviors + +For more examples, check the `${CLAUDE_PLUGIN_ROOT}/examples/` directory. diff --git a/plugins/hookify/commands/hookify.md b/plugins/hookify/commands/hookify.md new file mode 100644 index 00000000..e5fc645a --- /dev/null +++ b/plugins/hookify/commands/hookify.md @@ -0,0 +1,231 @@ +--- +description: Create hooks to prevent unwanted behaviors from conversation analysis or explicit instructions +argument-hint: Optional specific behavior to address +allowed-tools: ["Read", "Write", "AskUserQuestion", "Task", "Grep", "TodoWrite", "Skill"] +--- + +# Hookify - Create Hooks from Unwanted Behaviors + +**FIRST: Load the hookify:writing-rules skill** using the Skill tool to understand rule file format and syntax. + +Create hook rules to prevent problematic behaviors by analyzing the conversation or from explicit user instructions. + +## Your Task + +You will help the user create hookify rules to prevent unwanted behaviors. Follow these steps: + +### Step 1: Gather Behavior Information + +**If $ARGUMENTS is provided:** +- User has given specific instructions: `$ARGUMENTS` +- Still analyze recent conversation (last 10-15 user messages) for additional context +- Look for examples of the behavior happening + +**If $ARGUMENTS is empty:** +- Launch the conversation-analyzer agent to find problematic behaviors +- Agent will scan user prompts for frustration signals +- Agent will return structured findings + +**To analyze conversation:** +Use the Task tool to launch conversation-analyzer agent: +``` +{ + "subagent_type": "general-purpose", + "description": "Analyze conversation for unwanted behaviors", + "prompt": "You are analyzing a Claude Code conversation to find behaviors the user wants to prevent. + +Read user messages in the current conversation and identify: +1. Explicit requests to avoid something (\"don't do X\", \"stop doing Y\") +2. Corrections or reversions (user fixing Claude's actions) +3. Frustrated reactions (\"why did you do X?\", \"I didn't ask for that\") +4. Repeated issues (same problem multiple times) + +For each issue found, extract: +- What tool was used (Bash, Edit, Write, etc.) +- Specific pattern or command +- Why it was problematic +- User's stated reason + +Return findings as a structured list with: +- category: Type of issue +- tool: Which tool was involved +- pattern: Regex or literal pattern to match +- context: What happened +- severity: high/medium/low + +Focus on the most recent issues (last 20-30 messages). Don't go back further unless explicitly asked." +} +``` + +### Step 2: Present Findings to User + +After gathering behaviors (from arguments or agent), present to user using AskUserQuestion: + +**Question 1: Which behaviors to hookify?** +- Header: "Create Rules" +- multiSelect: true +- Options: List each detected behavior (max 4) + - Label: Short description (e.g., "Block rm -rf") + - Description: Why it's problematic + +**Question 2: For each selected behavior, ask about action:** +- "Should this block the operation or just warn?" +- Options: + - "Just warn" (action: warn - shows message but allows) + - "Block operation" (action: block - prevents execution) + +**Question 3: Ask for example patterns:** +- "What patterns should trigger this rule?" +- Show detected patterns +- Allow user to refine or add more + +### Step 3: Generate Rule Files + +For each confirmed behavior, create a `.claude/hookify.{rule-name}.local.md` file: + +**Rule naming convention:** +- Use kebab-case +- Be descriptive: `block-dangerous-rm`, `warn-console-log`, `require-tests-before-stop` +- Start with action verb: block, warn, prevent, require + +**File format:** +```markdown +--- +name: {rule-name} +enabled: true +event: {bash|file|stop|prompt|all} +pattern: {regex pattern} +action: {warn|block} +--- + +{Message to show Claude when rule triggers} +``` + +**Action values:** +- `warn`: Show message but allow operation (default) +- `block`: Prevent operation or stop session + +**For more complex rules (multiple conditions):** +```markdown +--- +name: {rule-name} +enabled: true +event: file +conditions: + - field: file_path + operator: regex_match + pattern: \.env$ + - field: new_text + operator: contains + pattern: API_KEY +--- + +{Warning message} +``` + +### Step 4: Create Files and Confirm + +**IMPORTANT**: Rule files must be created in the current working directory's `.claude/` folder, NOT the plugin directory. + +Use the current working directory (where Claude Code was started) as the base path. + +1. Check if `.claude/` directory exists in current working directory + - If not, create it first with: `mkdir -p .claude` + +2. Use Write tool to create each `.claude/hookify.{name}.local.md` file + - Use relative path from current working directory: `.claude/hookify.{name}.local.md` + - The path should resolve to the project's .claude directory, not the plugin's + +3. Show user what was created: + ``` + Created 3 hookify rules: + - .claude/hookify.dangerous-rm.local.md + - .claude/hookify.console-log.local.md + - .claude/hookify.sensitive-files.local.md + + These rules will trigger on: + - dangerous-rm: Bash commands matching "rm -rf" + - console-log: Edits adding console.log statements + - sensitive-files: Edits to .env or credentials files + ``` + +4. Verify files were created in the correct location by listing them + +5. Inform user: **"Rules are active immediately - no restart needed!"** + + The hookify hooks are already loaded and will read your new rules on the next tool use. + +## Event Types Reference + +- **bash**: Matches Bash tool commands +- **file**: Matches Edit, Write, MultiEdit tools +- **stop**: Matches when agent wants to stop (use for completion checks) +- **prompt**: Matches when user submits prompts +- **all**: Matches all events + +## Pattern Writing Tips + +**Bash patterns:** +- Match dangerous commands: `rm\s+-rf|chmod\s+777|dd\s+if=` +- Match specific tools: `npm\s+install\s+|pip\s+install` + +**File patterns:** +- Match code patterns: `console\.log\(|eval\(|innerHTML\s*=` +- Match file paths: `\.env$|\.git/|node_modules/` + +**Stop patterns:** +- Check for missing steps: (check transcript or completion criteria) + +## Example Workflow + +**User says**: "/hookify Don't use rm -rf without asking me first" + +**Your response**: +1. Analyze: User wants to prevent rm -rf commands +2. Ask: "Should I block this command or just warn you?" +3. User selects: "Just warn" +4. Create `.claude/hookify.dangerous-rm.local.md`: + ```markdown + --- + name: warn-dangerous-rm + enabled: true + event: bash + pattern: rm\s+-rf + --- + + ⚠️ **Dangerous rm command detected** + + You requested to be warned before using rm -rf. + Please verify the path is correct. + ``` +5. Confirm: "Created hookify rule. It's active immediately - try triggering it!" + +## Important Notes + +- **No restart needed**: Rules take effect immediately on the next tool use +- **File location**: Create files in project's `.claude/` directory (current working directory), NOT the plugin's .claude/ +- **Regex syntax**: Use Python regex syntax (raw strings, no need to escape in YAML) +- **Action types**: Rules can `warn` (default) or `block` operations +- **Testing**: Test rules immediately after creating them + +## Troubleshooting + +**If rule file creation fails:** +1. Check current working directory with pwd +2. Ensure `.claude/` directory exists (create with mkdir if needed) +3. Use absolute path if needed: `{cwd}/.claude/hookify.{name}.local.md` +4. Verify file was created with Glob or ls + +**If rule doesn't trigger after creation:** +1. Verify file is in project `.claude/` not plugin `.claude/` +2. Check file with Read tool to ensure pattern is correct +3. Test pattern with: `python3 -c "import re; print(re.search(r'pattern', 'test text'))"` +4. Verify `enabled: true` in frontmatter +5. Remember: Rules work immediately, no restart needed + +**If blocking seems too strict:** +1. Change `action: block` to `action: warn` in the rule file +2. Or adjust the pattern to be more specific +3. Changes take effect on next tool use + +Use TodoWrite to track your progress through the steps. diff --git a/plugins/hookify/commands/list.md b/plugins/hookify/commands/list.md new file mode 100644 index 00000000..d6f810fb --- /dev/null +++ b/plugins/hookify/commands/list.md @@ -0,0 +1,82 @@ +--- +description: List all configured hookify rules +allowed-tools: ["Glob", "Read", "Skill"] +--- + +# List Hookify Rules + +**Load hookify:writing-rules skill first** to understand rule format. + +Show all configured hookify rules in the project. + +## Steps + +1. Use Glob tool to find all hookify rule files: + ``` + pattern: ".claude/hookify.*.local.md" + ``` + +2. For each file found: + - Use Read tool to read the file + - Extract frontmatter fields: name, enabled, event, pattern + - Extract message preview (first 100 chars) + +3. Present results in a table: + +``` +## Configured Hookify Rules + +| Name | Enabled | Event | Pattern | File | +|------|---------|-------|---------|------| +| warn-dangerous-rm | ✅ Yes | bash | rm\s+-rf | hookify.dangerous-rm.local.md | +| warn-console-log | ✅ Yes | file | console\.log\( | hookify.console-log.local.md | +| check-tests | ❌ No | stop | .* | hookify.require-tests.local.md | + +**Total**: 3 rules (2 enabled, 1 disabled) +``` + +4. For each rule, show a brief preview: +``` +### warn-dangerous-rm +**Event**: bash +**Pattern**: `rm\s+-rf` +**Message**: "⚠️ **Dangerous rm command detected!** This command could delete..." + +**Status**: ✅ Active +**File**: .claude/hookify.dangerous-rm.local.md +``` + +5. Add helpful footer: +``` +--- + +To modify a rule: Edit the .local.md file directly +To disable a rule: Set `enabled: false` in frontmatter +To enable a rule: Set `enabled: true` in frontmatter +To delete a rule: Remove the .local.md file +To create a rule: Use `/hookify` command + +**Remember**: Changes take effect immediately - no restart needed +``` + +## If No Rules Found + +If no hookify rules exist: + +``` +## No Hookify Rules Configured + +You haven't created any hookify rules yet. + +To get started: +1. Use `/hookify` to analyze conversation and create rules +2. Or manually create `.claude/hookify.my-rule.local.md` files +3. See `/hookify:help` for documentation + +Example: +``` +/hookify Warn me when I use console.log +``` + +Check `${CLAUDE_PLUGIN_ROOT}/examples/` for example rule files. +``` diff --git a/plugins/hookify/core/__init__.py b/plugins/hookify/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/hookify/core/config_loader.py b/plugins/hookify/core/config_loader.py new file mode 100644 index 00000000..fa2fc3e3 --- /dev/null +++ b/plugins/hookify/core/config_loader.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Configuration loader for hookify plugin. + +Loads and parses .claude/hookify.*.local.md files. +""" + +import os +import sys +import glob +import re +from typing import List, Optional, Dict, Any +from dataclasses import dataclass, field + + +@dataclass +class Condition: + """A single condition for matching.""" + field: str # "command", "new_text", "old_text", "file_path", etc. + operator: str # "regex_match", "contains", "equals", etc. + pattern: str # Pattern to match + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Condition': + """Create Condition from dict.""" + return cls( + field=data.get('field', ''), + operator=data.get('operator', 'regex_match'), + pattern=data.get('pattern', '') + ) + + +@dataclass +class Rule: + """A hookify rule.""" + name: str + enabled: bool + event: str # "bash", "file", "stop", "all", etc. + pattern: Optional[str] = None # Simple pattern (legacy) + conditions: List[Condition] = field(default_factory=list) + action: str = "warn" # "warn" or "block" (future) + tool_matcher: Optional[str] = None # Override tool matching + message: str = "" # Message body from markdown + + @classmethod + def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule': + """Create Rule from frontmatter dict and message body.""" + # Handle both simple pattern and complex conditions + conditions = [] + + # New style: explicit conditions list + if 'conditions' in frontmatter: + cond_list = frontmatter['conditions'] + if isinstance(cond_list, list): + conditions = [Condition.from_dict(c) for c in cond_list] + + # Legacy style: simple pattern field + simple_pattern = frontmatter.get('pattern') + if simple_pattern and not conditions: + # Convert simple pattern to condition + # Infer field from event + event = frontmatter.get('event', 'all') + if event == 'bash': + field = 'command' + elif event == 'file': + field = 'new_text' + else: + field = 'content' + + conditions = [Condition( + field=field, + operator='regex_match', + pattern=simple_pattern + )] + + return cls( + name=frontmatter.get('name', 'unnamed'), + enabled=frontmatter.get('enabled', True), + event=frontmatter.get('event', 'all'), + pattern=simple_pattern, + conditions=conditions, + action=frontmatter.get('action', 'warn'), + tool_matcher=frontmatter.get('tool_matcher'), + message=message.strip() + ) + + +def extract_frontmatter(content: str) -> tuple[Dict[str, Any], str]: + """Extract YAML frontmatter and message body from markdown. + + Returns (frontmatter_dict, message_body). + + Supports multi-line dictionary items in lists by preserving indentation. + """ + if not content.startswith('---'): + return {}, content + + # Split on --- markers + parts = content.split('---', 2) + if len(parts) < 3: + return {}, content + + frontmatter_text = parts[1] + message = parts[2].strip() + + # Simple YAML parser that handles indented list items + frontmatter = {} + lines = frontmatter_text.split('\n') + + current_key = None + current_list = [] + current_dict = {} + in_list = False + in_dict_item = False + + for line in lines: + # Skip empty lines and comments + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + + # Check indentation level + indent = len(line) - len(line.lstrip()) + + # Top-level key (no indentation or minimal) + if indent == 0 and ':' in line and not line.strip().startswith('-'): + # Save previous list/dict if any + if in_list and current_key: + if in_dict_item and current_dict: + current_list.append(current_dict) + current_dict = {} + frontmatter[current_key] = current_list + in_list = False + in_dict_item = False + current_list = [] + + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + + if not value: + # Empty value - list or nested structure follows + current_key = key + in_list = True + current_list = [] + else: + # Simple key-value pair + value = value.strip('"').strip("'") + if value.lower() == 'true': + value = True + elif value.lower() == 'false': + value = False + frontmatter[key] = value + + # List item (starts with -) + elif stripped.startswith('-') and in_list: + # Save previous dict item if any + if in_dict_item and current_dict: + current_list.append(current_dict) + current_dict = {} + + item_text = stripped[1:].strip() + + # Check if this is an inline dict (key: value on same line) + if ':' in item_text and ',' in item_text: + # Inline comma-separated dict: "- field: command, operator: regex_match" + item_dict = {} + for part in item_text.split(','): + if ':' in part: + k, v = part.split(':', 1) + item_dict[k.strip()] = v.strip().strip('"').strip("'") + current_list.append(item_dict) + in_dict_item = False + elif ':' in item_text: + # Start of multi-line dict item: "- field: command" + in_dict_item = True + k, v = item_text.split(':', 1) + current_dict = {k.strip(): v.strip().strip('"').strip("'")} + else: + # Simple list item + current_list.append(item_text.strip('"').strip("'")) + in_dict_item = False + + # Continuation of dict item (indented under list item) + elif indent > 2 and in_dict_item and ':' in line: + # This is a field of the current dict item + k, v = stripped.split(':', 1) + current_dict[k.strip()] = v.strip().strip('"').strip("'") + + # Save final list/dict if any + if in_list and current_key: + if in_dict_item and current_dict: + current_list.append(current_dict) + frontmatter[current_key] = current_list + + return frontmatter, message + + +def load_rules(event: Optional[str] = None) -> List[Rule]: + """Load all hookify rules from .claude directory. + + Args: + event: Optional event filter ("bash", "file", "stop", etc.) + + Returns: + List of enabled Rule objects matching the event. + """ + rules = [] + + # Find all hookify.*.local.md files + pattern = os.path.join('.claude', 'hookify.*.local.md') + files = glob.glob(pattern) + + for file_path in files: + try: + rule = load_rule_file(file_path) + if not rule: + continue + + # Filter by event if specified + if event: + if rule.event != 'all' and rule.event != event: + continue + + # Only include enabled rules + if rule.enabled: + rules.append(rule) + + except (IOError, OSError, PermissionError) as e: + # File I/O errors - log and continue + print(f"Warning: Failed to read {file_path}: {e}", file=sys.stderr) + continue + except (ValueError, KeyError, AttributeError, TypeError) as e: + # Parsing errors - log and continue + print(f"Warning: Failed to parse {file_path}: {e}", file=sys.stderr) + continue + except Exception as e: + # Unexpected errors - log with type details + print(f"Warning: Unexpected error loading {file_path} ({type(e).__name__}): {e}", file=sys.stderr) + continue + + return rules + + +def load_rule_file(file_path: str) -> Optional[Rule]: + """Load a single rule file. + + Returns: + Rule object or None if file is invalid. + """ + try: + with open(file_path, 'r') as f: + content = f.read() + + frontmatter, message = extract_frontmatter(content) + + if not frontmatter: + print(f"Warning: {file_path} missing YAML frontmatter (must start with ---)", file=sys.stderr) + return None + + rule = Rule.from_dict(frontmatter, message) + return rule + + except (IOError, OSError, PermissionError) as e: + print(f"Error: Cannot read {file_path}: {e}", file=sys.stderr) + return None + except (ValueError, KeyError, AttributeError, TypeError) as e: + print(f"Error: Malformed rule file {file_path}: {e}", file=sys.stderr) + return None + except UnicodeDecodeError as e: + print(f"Error: Invalid encoding in {file_path}: {e}", file=sys.stderr) + return None + except Exception as e: + print(f"Error: Unexpected error parsing {file_path} ({type(e).__name__}): {e}", file=sys.stderr) + return None + + +# For testing +if __name__ == '__main__': + import sys + + # Test frontmatter parsing + test_content = """--- +name: test-rule +enabled: true +event: bash +pattern: "rm -rf" +--- + +⚠️ Dangerous command detected! +""" + + fm, msg = extract_frontmatter(test_content) + print("Frontmatter:", fm) + print("Message:", msg) + + rule = Rule.from_dict(fm, msg) + print("Rule:", rule) diff --git a/plugins/hookify/core/rule_engine.py b/plugins/hookify/core/rule_engine.py new file mode 100644 index 00000000..8244c005 --- /dev/null +++ b/plugins/hookify/core/rule_engine.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Rule evaluation engine for hookify plugin.""" + +import re +import sys +from functools import lru_cache +from typing import List, Dict, Any, Optional + +# Import from local module +from hookify.core.config_loader import Rule, Condition + + +# Cache compiled regexes (max 128 patterns) +@lru_cache(maxsize=128) +def compile_regex(pattern: str) -> re.Pattern: + """Compile regex pattern with caching. + + Args: + pattern: Regex pattern string + + Returns: + Compiled regex pattern + """ + return re.compile(pattern, re.IGNORECASE) + + +class RuleEngine: + """Evaluates rules against hook input data.""" + + def __init__(self): + """Initialize rule engine.""" + # No need for instance cache anymore - using global lru_cache + pass + + def evaluate_rules(self, rules: List[Rule], input_data: Dict[str, Any]) -> Dict[str, Any]: + """Evaluate all rules and return combined results. + + Checks all rules and accumulates matches. Blocking rules take priority + over warning rules. All matching rule messages are combined. + + Args: + rules: List of Rule objects to evaluate + input_data: Hook input JSON (tool_name, tool_input, etc.) + + Returns: + Response dict with systemMessage, hookSpecificOutput, etc. + Empty dict {} if no rules match. + """ + hook_event = input_data.get('hook_event_name', '') + blocking_rules = [] + warning_rules = [] + + for rule in rules: + if self._rule_matches(rule, input_data): + if rule.action == 'block': + blocking_rules.append(rule) + else: + warning_rules.append(rule) + + # If any blocking rules matched, block the operation + if blocking_rules: + messages = [f"**[{r.name}]**\n{r.message}" for r in blocking_rules] + combined_message = "\n\n".join(messages) + + # Use appropriate blocking format based on event type + if hook_event == 'Stop': + return { + "decision": "block", + "reason": combined_message, + "systemMessage": combined_message + } + elif hook_event in ['PreToolUse', 'PostToolUse']: + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny" + }, + "systemMessage": combined_message + } + else: + # For other events, just show message + return { + "systemMessage": combined_message + } + + # If only warnings, show them but allow operation + if warning_rules: + messages = [f"**[{r.name}]**\n{r.message}" for r in warning_rules] + return { + "systemMessage": "\n\n".join(messages) + } + + # No matches - allow operation + return {} + + def _rule_matches(self, rule: Rule, input_data: Dict[str, Any]) -> bool: + """Check if rule matches input data. + + Args: + rule: Rule to evaluate + input_data: Hook input data + + Returns: + True if rule matches, False otherwise + """ + # Extract tool information + tool_name = input_data.get('tool_name', '') + tool_input = input_data.get('tool_input', {}) + + # Check tool matcher if specified + if rule.tool_matcher: + if not self._matches_tool(rule.tool_matcher, tool_name): + return False + + # If no conditions, don't match + # (Rules must have at least one condition to be valid) + if not rule.conditions: + return False + + # All conditions must match + for condition in rule.conditions: + if not self._check_condition(condition, tool_name, tool_input, input_data): + return False + + return True + + def _matches_tool(self, matcher: str, tool_name: str) -> bool: + """Check if tool_name matches the matcher pattern. + + Args: + matcher: Pattern like "Bash", "Edit|Write", "*" + tool_name: Actual tool name + + Returns: + True if matches + """ + if matcher == '*': + return True + + # Split on | for OR matching + patterns = matcher.split('|') + return tool_name in patterns + + def _check_condition(self, condition: Condition, tool_name: str, + tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool: + """Check if a single condition matches. + + Args: + condition: Condition to check + tool_name: Tool being used + tool_input: Tool input dict + input_data: Full hook input data (for Stop events, etc.) + + Returns: + True if condition matches + """ + # Extract the field value to check + field_value = self._extract_field(condition.field, tool_name, tool_input, input_data) + if field_value is None: + return False + + # Apply operator + operator = condition.operator + pattern = condition.pattern + + if operator == 'regex_match': + return self._regex_match(pattern, field_value) + elif operator == 'contains': + return pattern in field_value + elif operator == 'equals': + return pattern == field_value + elif operator == 'not_contains': + return pattern not in field_value + elif operator == 'starts_with': + return field_value.startswith(pattern) + elif operator == 'ends_with': + return field_value.endswith(pattern) + else: + # Unknown operator + return False + + def _extract_field(self, field: str, tool_name: str, + tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> Optional[str]: + """Extract field value from tool input or hook input data. + + Args: + field: Field name like "command", "new_text", "file_path", "reason", "transcript" + tool_name: Tool being used (may be empty for Stop events) + tool_input: Tool input dict + input_data: Full hook input (for accessing transcript_path, reason, etc.) + + Returns: + Field value as string, or None if not found + """ + # Direct tool_input fields + if field in tool_input: + value = tool_input[field] + if isinstance(value, str): + return value + return str(value) + + # For Stop events and other non-tool events, check input_data + if input_data: + # Stop event specific fields + if field == 'reason': + return input_data.get('reason', '') + elif field == 'transcript': + # Read transcript file if path provided + transcript_path = input_data.get('transcript_path') + if transcript_path: + try: + with open(transcript_path, 'r') as f: + return f.read() + except FileNotFoundError: + print(f"Warning: Transcript file not found: {transcript_path}", file=sys.stderr) + return '' + except PermissionError: + print(f"Warning: Permission denied reading transcript: {transcript_path}", file=sys.stderr) + return '' + except (IOError, OSError) as e: + print(f"Warning: Error reading transcript {transcript_path}: {e}", file=sys.stderr) + return '' + except UnicodeDecodeError as e: + print(f"Warning: Encoding error in transcript {transcript_path}: {e}", file=sys.stderr) + return '' + elif field == 'user_prompt': + # For UserPromptSubmit events + return input_data.get('user_prompt', '') + + # Handle special cases by tool type + if tool_name == 'Bash': + if field == 'command': + return tool_input.get('command', '') + + elif tool_name in ['Write', 'Edit']: + if field == 'content': + # Write uses 'content', Edit has 'new_string' + return tool_input.get('content') or tool_input.get('new_string', '') + elif field == 'new_text' or field == 'new_string': + return tool_input.get('new_string', '') + elif field == 'old_text' or field == 'old_string': + return tool_input.get('old_string', '') + elif field == 'file_path': + return tool_input.get('file_path', '') + + elif tool_name == 'MultiEdit': + if field == 'file_path': + return tool_input.get('file_path', '') + elif field in ['new_text', 'content']: + # Concatenate all edits + edits = tool_input.get('edits', []) + return ' '.join(e.get('new_string', '') for e in edits) + + return None + + def _regex_match(self, pattern: str, text: str) -> bool: + """Check if pattern matches text using regex. + + Args: + pattern: Regex pattern + text: Text to match against + + Returns: + True if pattern matches + """ + try: + # Use cached compiled regex (LRU cache with max 128 patterns) + regex = compile_regex(pattern) + return bool(regex.search(text)) + + except re.error as e: + print(f"Invalid regex pattern '{pattern}': {e}", file=sys.stderr) + return False + + +# For testing +if __name__ == '__main__': + from hookify.core.config_loader import Condition, Rule + + # Test rule evaluation + rule = Rule( + name="test-rm", + enabled=True, + event="bash", + conditions=[ + Condition(field="command", operator="regex_match", pattern=r"rm\s+-rf") + ], + message="Dangerous rm command!" + ) + + engine = RuleEngine() + + # Test matching input + test_input = { + "tool_name": "Bash", + "tool_input": { + "command": "rm -rf /tmp/test" + } + } + + result = engine.evaluate_rules([rule], test_input) + print("Match result:", result) + + # Test non-matching input + test_input2 = { + "tool_name": "Bash", + "tool_input": { + "command": "ls -la" + } + } + + result2 = engine.evaluate_rules([rule], test_input2) + print("Non-match result:", result2) diff --git a/plugins/hookify/examples/console-log-warning.local.md b/plugins/hookify/examples/console-log-warning.local.md new file mode 100644 index 00000000..c9352e75 --- /dev/null +++ b/plugins/hookify/examples/console-log-warning.local.md @@ -0,0 +1,14 @@ +--- +name: warn-console-log +enabled: true +event: file +pattern: console\.log\( +action: warn +--- + +🔍 **Console.log detected** + +You're adding a console.log statement. Please consider: +- Is this for debugging or should it be proper logging? +- Will this ship to production? +- Should this use a logging library instead? diff --git a/plugins/hookify/examples/dangerous-rm.local.md b/plugins/hookify/examples/dangerous-rm.local.md new file mode 100644 index 00000000..8226eb19 --- /dev/null +++ b/plugins/hookify/examples/dangerous-rm.local.md @@ -0,0 +1,14 @@ +--- +name: block-dangerous-rm +enabled: true +event: bash +pattern: rm\s+-rf +action: block +--- + +⚠️ **Dangerous rm command detected!** + +This command could delete important files. Please: +- Verify the path is correct +- Consider using a safer approach +- Make sure you have backups diff --git a/plugins/hookify/examples/require-tests-stop.local.md b/plugins/hookify/examples/require-tests-stop.local.md new file mode 100644 index 00000000..87039186 --- /dev/null +++ b/plugins/hookify/examples/require-tests-stop.local.md @@ -0,0 +1,22 @@ +--- +name: require-tests-run +enabled: false +event: stop +action: block +conditions: + - field: transcript + operator: not_contains + pattern: npm test|pytest|cargo test +--- + +**Tests not detected in transcript!** + +Before stopping, please run tests to verify your changes work correctly. + +Look for test commands like: +- `npm test` +- `pytest` +- `cargo test` + +**Note:** This rule blocks stopping if no test commands appear in the transcript. +Enable this rule only when you want strict test enforcement. diff --git a/plugins/hookify/examples/sensitive-files-warning.local.md b/plugins/hookify/examples/sensitive-files-warning.local.md new file mode 100644 index 00000000..ae92971d --- /dev/null +++ b/plugins/hookify/examples/sensitive-files-warning.local.md @@ -0,0 +1,18 @@ +--- +name: warn-sensitive-files +enabled: true +event: file +action: warn +conditions: + - field: file_path + operator: regex_match + pattern: \.env$|\.env\.|credentials|secrets +--- + +🔐 **Sensitive file detected** + +You're editing a file that may contain sensitive data: +- Ensure credentials are not hardcoded +- Use environment variables for secrets +- Verify this file is in .gitignore +- Consider using a secrets manager diff --git a/plugins/hookify/hooks/__init__.py b/plugins/hookify/hooks/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/plugins/hookify/hooks/hooks.json b/plugins/hookify/hooks/hooks.json new file mode 100644 index 00000000..d65daca7 --- /dev/null +++ b/plugins/hookify/hooks/hooks.json @@ -0,0 +1,49 @@ +{ + "description": "Hookify plugin - User-configurable hooks from .local.md files", + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop.py", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.py", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugins/hookify/hooks/posttooluse.py b/plugins/hookify/hooks/posttooluse.py new file mode 100755 index 00000000..a9e12cc7 --- /dev/null +++ b/plugins/hookify/hooks/posttooluse.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""PostToolUse hook executor for hookify plugin. + +This script is called by Claude Code after a tool executes. +It reads .claude/hookify.*.local.md files and evaluates rules. +""" + +import os +import sys +import json + +# CRITICAL: Add plugin root to Python path for imports +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') +if PLUGIN_ROOT: + parent_dir = os.path.dirname(PLUGIN_ROOT) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + +try: + from hookify.core.config_loader import load_rules + from hookify.core.rule_engine import RuleEngine +except ImportError as e: + error_msg = {"systemMessage": f"Hookify import error: {e}"} + print(json.dumps(error_msg), file=sys.stdout) + sys.exit(0) + + +def main(): + """Main entry point for PostToolUse hook.""" + try: + # Read input from stdin + input_data = json.load(sys.stdin) + + # Determine event type based on tool + tool_name = input_data.get('tool_name', '') + event = None + if tool_name == 'Bash': + event = 'bash' + elif tool_name in ['Edit', 'Write', 'MultiEdit']: + event = 'file' + + # Load rules + rules = load_rules(event=event) + + # Evaluate rules + engine = RuleEngine() + result = engine.evaluate_rules(rules, input_data) + + # Always output JSON (even if empty) + print(json.dumps(result), file=sys.stdout) + + except Exception as e: + error_output = { + "systemMessage": f"Hookify error: {str(e)}" + } + print(json.dumps(error_output), file=sys.stdout) + + finally: + # ALWAYS exit 0 + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/plugins/hookify/hooks/pretooluse.py b/plugins/hookify/hooks/pretooluse.py new file mode 100755 index 00000000..f265c277 --- /dev/null +++ b/plugins/hookify/hooks/pretooluse.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""PreToolUse hook executor for hookify plugin. + +This script is called by Claude Code before any tool executes. +It reads .claude/hookify.*.local.md files and evaluates rules. +""" + +import os +import sys +import json + +# CRITICAL: Add plugin root to Python path for imports +# We need to add the parent of the plugin directory so Python can find "hookify" package +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') +if PLUGIN_ROOT: + # Add the parent directory of the plugin + parent_dir = os.path.dirname(PLUGIN_ROOT) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Also add PLUGIN_ROOT itself in case we have other scripts + if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + +try: + from hookify.core.config_loader import load_rules + from hookify.core.rule_engine import RuleEngine +except ImportError as e: + # If imports fail, allow operation and log error + error_msg = {"systemMessage": f"Hookify import error: {e}"} + print(json.dumps(error_msg), file=sys.stdout) + sys.exit(0) + + +def main(): + """Main entry point for PreToolUse hook.""" + try: + # Read input from stdin + input_data = json.load(sys.stdin) + + # Determine event type for filtering + # For PreToolUse, we use tool_name to determine "bash" vs "file" event + tool_name = input_data.get('tool_name', '') + + event = None + if tool_name == 'Bash': + event = 'bash' + elif tool_name in ['Edit', 'Write', 'MultiEdit']: + event = 'file' + + # Load rules + rules = load_rules(event=event) + + # Evaluate rules + engine = RuleEngine() + result = engine.evaluate_rules(rules, input_data) + + # Always output JSON (even if empty) + print(json.dumps(result), file=sys.stdout) + + except Exception as e: + # On any error, allow the operation and log + error_output = { + "systemMessage": f"Hookify error: {str(e)}" + } + print(json.dumps(error_output), file=sys.stdout) + + finally: + # ALWAYS exit 0 - never block operations due to hook errors + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/plugins/hookify/hooks/stop.py b/plugins/hookify/hooks/stop.py new file mode 100755 index 00000000..fc299bc6 --- /dev/null +++ b/plugins/hookify/hooks/stop.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Stop hook executor for hookify plugin. + +This script is called by Claude Code when agent wants to stop. +It reads .claude/hookify.*.local.md files and evaluates stop rules. +""" + +import os +import sys +import json + +# CRITICAL: Add plugin root to Python path for imports +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') +if PLUGIN_ROOT: + parent_dir = os.path.dirname(PLUGIN_ROOT) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + +try: + from hookify.core.config_loader import load_rules + from hookify.core.rule_engine import RuleEngine +except ImportError as e: + error_msg = {"systemMessage": f"Hookify import error: {e}"} + print(json.dumps(error_msg), file=sys.stdout) + sys.exit(0) + + +def main(): + """Main entry point for Stop hook.""" + try: + # Read input from stdin + input_data = json.load(sys.stdin) + + # Load stop rules + rules = load_rules(event='stop') + + # Evaluate rules + engine = RuleEngine() + result = engine.evaluate_rules(rules, input_data) + + # Always output JSON (even if empty) + print(json.dumps(result), file=sys.stdout) + + except Exception as e: + # On any error, allow the operation + error_output = { + "systemMessage": f"Hookify error: {str(e)}" + } + print(json.dumps(error_output), file=sys.stdout) + + finally: + # ALWAYS exit 0 + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/plugins/hookify/hooks/userpromptsubmit.py b/plugins/hookify/hooks/userpromptsubmit.py new file mode 100755 index 00000000..28ee51fd --- /dev/null +++ b/plugins/hookify/hooks/userpromptsubmit.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook executor for hookify plugin. + +This script is called by Claude Code when user submits a prompt. +It reads .claude/hookify.*.local.md files and evaluates rules. +""" + +import os +import sys +import json + +# CRITICAL: Add plugin root to Python path for imports +PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT') +if PLUGIN_ROOT: + parent_dir = os.path.dirname(PLUGIN_ROOT) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + +try: + from hookify.core.config_loader import load_rules + from hookify.core.rule_engine import RuleEngine +except ImportError as e: + error_msg = {"systemMessage": f"Hookify import error: {e}"} + print(json.dumps(error_msg), file=sys.stdout) + sys.exit(0) + + +def main(): + """Main entry point for UserPromptSubmit hook.""" + try: + # Read input from stdin + input_data = json.load(sys.stdin) + + # Load user prompt rules + rules = load_rules(event='prompt') + + # Evaluate rules + engine = RuleEngine() + result = engine.evaluate_rules(rules, input_data) + + # Always output JSON (even if empty) + print(json.dumps(result), file=sys.stdout) + + except Exception as e: + error_output = { + "systemMessage": f"Hookify error: {str(e)}" + } + print(json.dumps(error_output), file=sys.stdout) + + finally: + # ALWAYS exit 0 + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/plugins/hookify/matchers/__init__.py b/plugins/hookify/matchers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/hookify/skills/writing-rules/SKILL.md b/plugins/hookify/skills/writing-rules/SKILL.md new file mode 100644 index 00000000..008168a4 --- /dev/null +++ b/plugins/hookify/skills/writing-rules/SKILL.md @@ -0,0 +1,374 @@ +--- +name: Writing Hookify Rules +description: This skill should be used when the user asks to "create a hookify rule", "write a hook rule", "configure hookify", "add a hookify rule", or needs guidance on hookify rule syntax and patterns. +version: 0.1.0 +--- + +# Writing Hookify Rules + +## Overview + +Hookify rules are markdown files with YAML frontmatter that define patterns to watch for and messages to show when those patterns match. Rules are stored in `.claude/hookify.{rule-name}.local.md` files. + +## Rule File Format + +### Basic Structure + +```markdown +--- +name: rule-identifier +enabled: true +event: bash|file|stop|prompt|all +pattern: regex-pattern-here +--- + +Message to show Claude when this rule triggers. +Can include markdown formatting, warnings, suggestions, etc. +``` + +### Frontmatter Fields + +**name** (required): Unique identifier for the rule +- Use kebab-case: `warn-dangerous-rm`, `block-console-log` +- Be descriptive and action-oriented +- Start with verb: warn, prevent, block, require, check + +**enabled** (required): Boolean to activate/deactivate +- `true`: Rule is active +- `false`: Rule is disabled (won't trigger) +- Can toggle without deleting rule + +**event** (required): Which hook event to trigger on +- `bash`: Bash tool commands +- `file`: Edit, Write, MultiEdit tools +- `stop`: When agent wants to stop +- `prompt`: When user submits a prompt +- `all`: All events + +**action** (optional): What to do when rule matches +- `warn`: Show message but allow operation (default) +- `block`: Prevent operation (PreToolUse) or stop session (Stop events) +- If omitted, defaults to `warn` + +**pattern** (simple format): Regex pattern to match +- Used for simple single-condition rules +- Matches against command (bash) or new_text (file) +- Python regex syntax + +**Example:** +```yaml +event: bash +pattern: rm\s+-rf +``` + +### Advanced Format (Multiple Conditions) + +For complex rules with multiple conditions: + +```markdown +--- +name: warn-env-file-edits +enabled: true +event: file +conditions: + - field: file_path + operator: regex_match + pattern: \.env$ + - field: new_text + operator: contains + pattern: API_KEY +--- + +You're adding an API key to a .env file. Ensure this file is in .gitignore! +``` + +**Condition fields:** +- `field`: Which field to check + - For bash: `command` + - For file: `file_path`, `new_text`, `old_text`, `content` +- `operator`: How to match + - `regex_match`: Regex pattern matching + - `contains`: Substring check + - `equals`: Exact match + - `not_contains`: Substring must NOT be present + - `starts_with`: Prefix check + - `ends_with`: Suffix check +- `pattern`: Pattern or string to match + +**All conditions must match for rule to trigger.** + +## Message Body + +The markdown content after frontmatter is shown to Claude when the rule triggers. + +**Good messages:** +- Explain what was detected +- Explain why it's problematic +- Suggest alternatives or best practices +- Use formatting for clarity (bold, lists, etc.) + +**Example:** +```markdown +⚠️ **Console.log detected!** + +You're adding console.log to production code. + +**Why this matters:** +- Debug logs shouldn't ship to production +- Console.log can expose sensitive data +- Impacts browser performance + +**Alternatives:** +- Use a proper logging library +- Remove before committing +- Use conditional debug builds +``` + +## Event Type Guide + +### bash Events + +Match Bash command patterns: + +```markdown +--- +event: bash +pattern: sudo\s+|rm\s+-rf|chmod\s+777 +--- + +Dangerous command detected! +``` + +**Common patterns:** +- Dangerous commands: `rm\s+-rf`, `dd\s+if=`, `mkfs` +- Privilege escalation: `sudo\s+`, `su\s+` +- Permission issues: `chmod\s+777`, `chown\s+root` + +### file Events + +Match Edit/Write/MultiEdit operations: + +```markdown +--- +event: file +pattern: console\.log\(|eval\(|innerHTML\s*= +--- + +Potentially problematic code pattern detected! +``` + +**Match on different fields:** +```markdown +--- +event: file +conditions: + - field: file_path + operator: regex_match + pattern: \.tsx?$ + - field: new_text + operator: regex_match + pattern: console\.log\( +--- + +Console.log in TypeScript file! +``` + +**Common patterns:** +- Debug code: `console\.log\(`, `debugger`, `print\(` +- Security risks: `eval\(`, `innerHTML\s*=`, `dangerouslySetInnerHTML` +- Sensitive files: `\.env$`, `credentials`, `\.pem$` +- Generated files: `node_modules/`, `dist/`, `build/` + +### stop Events + +Match when agent wants to stop (completion checks): + +```markdown +--- +event: stop +pattern: .* +--- + +Before stopping, verify: +- [ ] Tests were run +- [ ] Build succeeded +- [ ] Documentation updated +``` + +**Use for:** +- Reminders about required steps +- Completion checklists +- Process enforcement + +### prompt Events + +Match user prompt content (advanced): + +```markdown +--- +event: prompt +conditions: + - field: user_prompt + operator: contains + pattern: deploy to production +--- + +Production deployment checklist: +- [ ] Tests passing? +- [ ] Reviewed by team? +- [ ] Monitoring ready? +``` + +## Pattern Writing Tips + +### Regex Basics + +**Literal characters:** Most characters match themselves +- `rm` matches "rm" +- `console.log` matches "console.log" + +**Special characters need escaping:** +- `.` (any char) → `\.` (literal dot) +- `(` `)` → `\(` `\)` (literal parens) +- `[` `]` → `\[` `\]` (literal brackets) + +**Common metacharacters:** +- `\s` - whitespace (space, tab, newline) +- `\d` - digit (0-9) +- `\w` - word character (a-z, A-Z, 0-9, _) +- `.` - any character +- `+` - one or more +- `*` - zero or more +- `?` - zero or one +- `|` - OR + +**Examples:** +``` +rm\s+-rf Matches: rm -rf, rm -rf +console\.log\( Matches: console.log( +(eval|exec)\( Matches: eval( or exec( +chmod\s+777 Matches: chmod 777, chmod 777 +API_KEY\s*= Matches: API_KEY=, API_KEY = +``` + +### Testing Patterns + +Test regex patterns before using: + +```bash +python3 -c "import re; print(re.search(r'your_pattern', 'test text'))" +``` + +Or use online regex testers (regex101.com with Python flavor). + +### Common Pitfalls + +**Too broad:** +```yaml +pattern: log # Matches "log", "login", "dialog", "catalog" +``` +Better: `console\.log\(|logger\.` + +**Too specific:** +```yaml +pattern: rm -rf /tmp # Only matches exact path +``` +Better: `rm\s+-rf` + +**Escaping issues:** +- YAML quoted strings: `"pattern"` requires double backslashes `\\s` +- YAML unquoted: `pattern: \s` works as-is +- **Recommendation**: Use unquoted patterns in YAML + +## File Organization + +**Location:** All rules in `.claude/` directory +**Naming:** `.claude/hookify.{descriptive-name}.local.md` +**Gitignore:** Add `.claude/*.local.md` to `.gitignore` + +**Good names:** +- `hookify.dangerous-rm.local.md` +- `hookify.console-log.local.md` +- `hookify.require-tests.local.md` +- `hookify.sensitive-files.local.md` + +**Bad names:** +- `hookify.rule1.local.md` (not descriptive) +- `hookify.md` (missing .local) +- `danger.local.md` (missing hookify prefix) + +## Workflow + +### Creating a Rule + +1. Identify unwanted behavior +2. Determine which tool is involved (Bash, Edit, etc.) +3. Choose event type (bash, file, stop, etc.) +4. Write regex pattern +5. Create `.claude/hookify.{name}.local.md` file in project root +6. Test immediately - rules are read dynamically on next tool use + +### Refining a Rule + +1. Edit the `.local.md` file +2. Adjust pattern or message +3. Test immediately - changes take effect on next tool use + +### Disabling a Rule + +**Temporary:** Set `enabled: false` in frontmatter +**Permanent:** Delete the `.local.md` file + +## Examples + +See `${CLAUDE_PLUGIN_ROOT}/examples/` for complete examples: +- `dangerous-rm.local.md` - Block dangerous rm commands +- `console-log-warning.local.md` - Warn about console.log +- `sensitive-files-warning.local.md` - Warn about editing .env files + +## Quick Reference + +**Minimum viable rule:** +```markdown +--- +name: my-rule +enabled: true +event: bash +pattern: dangerous_command +--- + +Warning message here +``` + +**Rule with conditions:** +```markdown +--- +name: my-rule +enabled: true +event: file +conditions: + - field: file_path + operator: regex_match + pattern: \.ts$ + - field: new_text + operator: contains + pattern: any +--- + +Warning message +``` + +**Event types:** +- `bash` - Bash commands +- `file` - File edits +- `stop` - Completion checks +- `prompt` - User input +- `all` - All events + +**Field options:** +- Bash: `command` +- File: `file_path`, `new_text`, `old_text`, `content` +- Prompt: `user_prompt` + +**Operators:** +- `regex_match`, `contains`, `equals`, `not_contains`, `starts_with`, `ends_with` diff --git a/plugins/hookify/utils/__init__.py b/plugins/hookify/utils/__init__.py new file mode 100644 index 00000000..e69de29b