mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-01-31 04:52:02 +00:00
creating intital scaffolding for claude code plugins
This commit is contained in:
9
plugins/hookify/.claude-plugin/plugin.json
Normal file
9
plugins/hookify/.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
30
plugins/hookify/.gitignore
vendored
Normal file
30
plugins/hookify/.gitignore
vendored
Normal file
@@ -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
|
||||
340
plugins/hookify/README.md
Normal file
340
plugins/hookify/README.md
Normal file
@@ -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
|
||||
176
plugins/hookify/agents/conversation-analyzer.md
Normal file
176
plugins/hookify/agents/conversation-analyzer.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: conversation-analyzer
|
||||
description: Use this agent when analyzing conversation transcripts to find behaviors worth preventing with hooks. Examples: <example>Context: User is running /hookify command without arguments\nuser: "/hookify"\nassistant: "I'll analyze the conversation to find behaviors you want to prevent"\n<commentary>The /hookify command without arguments triggers conversation analysis to find unwanted behaviors.</commentary></example><example>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."\n<commentary>User explicitly asks to analyze conversation for mistakes that should be prevented.</commentary></example>
|
||||
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
|
||||
128
plugins/hookify/commands/configure.md
Normal file
128
plugins/hookify/commands/configure.md
Normal file
@@ -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
|
||||
175
plugins/hookify/commands/help.md
Normal file
175
plugins/hookify/commands/help.md
Normal file
@@ -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.
|
||||
231
plugins/hookify/commands/hookify.md
Normal file
231
plugins/hookify/commands/hookify.md
Normal file
@@ -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.
|
||||
82
plugins/hookify/commands/list.md
Normal file
82
plugins/hookify/commands/list.md
Normal file
@@ -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.
|
||||
```
|
||||
0
plugins/hookify/core/__init__.py
Normal file
0
plugins/hookify/core/__init__.py
Normal file
297
plugins/hookify/core/config_loader.py
Normal file
297
plugins/hookify/core/config_loader.py
Normal file
@@ -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)
|
||||
313
plugins/hookify/core/rule_engine.py
Normal file
313
plugins/hookify/core/rule_engine.py
Normal file
@@ -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)
|
||||
14
plugins/hookify/examples/console-log-warning.local.md
Normal file
14
plugins/hookify/examples/console-log-warning.local.md
Normal file
@@ -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?
|
||||
14
plugins/hookify/examples/dangerous-rm.local.md
Normal file
14
plugins/hookify/examples/dangerous-rm.local.md
Normal file
@@ -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
|
||||
22
plugins/hookify/examples/require-tests-stop.local.md
Normal file
22
plugins/hookify/examples/require-tests-stop.local.md
Normal file
@@ -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.
|
||||
18
plugins/hookify/examples/sensitive-files-warning.local.md
Normal file
18
plugins/hookify/examples/sensitive-files-warning.local.md
Normal file
@@ -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
|
||||
0
plugins/hookify/hooks/__init__.py
Executable file
0
plugins/hookify/hooks/__init__.py
Executable file
49
plugins/hookify/hooks/hooks.json
Normal file
49
plugins/hookify/hooks/hooks.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
66
plugins/hookify/hooks/posttooluse.py
Executable file
66
plugins/hookify/hooks/posttooluse.py
Executable file
@@ -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()
|
||||
74
plugins/hookify/hooks/pretooluse.py
Executable file
74
plugins/hookify/hooks/pretooluse.py
Executable file
@@ -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()
|
||||
59
plugins/hookify/hooks/stop.py
Executable file
59
plugins/hookify/hooks/stop.py
Executable file
@@ -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()
|
||||
58
plugins/hookify/hooks/userpromptsubmit.py
Executable file
58
plugins/hookify/hooks/userpromptsubmit.py
Executable file
@@ -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()
|
||||
0
plugins/hookify/matchers/__init__.py
Normal file
0
plugins/hookify/matchers/__init__.py
Normal file
374
plugins/hookify/skills/writing-rules/SKILL.md
Normal file
374
plugins/hookify/skills/writing-rules/SKILL.md
Normal file
@@ -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`
|
||||
0
plugins/hookify/utils/__init__.py
Normal file
0
plugins/hookify/utils/__init__.py
Normal file
Reference in New Issue
Block a user