claude-dev-env 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/project-context-loader.md +1 -1
- package/bin/install.mjs +2 -1
- package/docs/PR_DESCRIPTION_GUIDE.md +95 -0
- package/hooks/blocking/block-main-commit.py +13 -1
- package/hooks/blocking/pr-description-enforcer.py +68 -43
- package/hooks/notification/attention-needed-notify.py +2 -1
- package/hooks/notification/notification_utils.py +12 -9
- package/hooks/validation/mypy_validator.py +35 -0
- package/hooks/workflow/auto-formatter.py +40 -1
- package/package.json +1 -1
- package/skills/npm-creator/SKILL.md +7 -3
- package/hooks/blocking/docker-settings-guard.py +0 -44
- package/hooks/blocking/parallel-task-blocker.py +0 -69
- package/hooks/blocking/pyautogui-scroll-blocker.py +0 -74
- package/hooks/session/bulk-edit-reminder.py +0 -30
- package/hooks/session/code-rules-reminder.py +0 -97
- package/hooks/session/compact-context-reinject.py +0 -39
- package/hooks/session/hook-structure-context.py +0 -140
- package/hooks/validation/code-style-validator.py +0 -145
- package/hooks/validation/e2e-test-validator.py +0 -142
package/bin/install.mjs
CHANGED
|
@@ -81,13 +81,14 @@ function mergeHooks(pythonCommand) {
|
|
|
81
81
|
}
|
|
82
82
|
if (!settings.hooks) settings.hooks = {};
|
|
83
83
|
const installedHooksDir = join(CLAUDE_HOME, 'hooks');
|
|
84
|
+
const pluginRootDir = CLAUDE_HOME;
|
|
84
85
|
let groupCount = 0;
|
|
85
86
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
86
87
|
if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
|
|
87
88
|
for (const sourceGroup of matcherGroups) {
|
|
88
89
|
const rewrittenHooks = sourceGroup.hooks.map(hook => {
|
|
89
90
|
let command = hook.command;
|
|
90
|
-
command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g,
|
|
91
|
+
command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRootDir.replace(/\\/g, '/'));
|
|
91
92
|
command = command.replace(/^python3\b/, pythonCommand);
|
|
92
93
|
return { ...hook, command };
|
|
93
94
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# GitHub PR Summary Writing Guide for AI
|
|
2
|
+
|
|
3
|
+
Use this guide when writing pull request descriptions. Follow these best practices to create clear, professional, and reviewable PR summaries.
|
|
4
|
+
|
|
5
|
+
## Required Sections
|
|
6
|
+
|
|
7
|
+
### What (Changes)
|
|
8
|
+
|
|
9
|
+
- Concise statement of what was changed
|
|
10
|
+
- What files or systems were modified
|
|
11
|
+
- What functionality was added, removed, or improved
|
|
12
|
+
- Keep to 2-3 sentences maximum
|
|
13
|
+
|
|
14
|
+
### Why (Problem/Context)
|
|
15
|
+
|
|
16
|
+
- Explain the problem this PR solves
|
|
17
|
+
- Provide business or technical context
|
|
18
|
+
- Reference related issue numbers using `#123` or `Fixes #123`, `Closes #456`
|
|
19
|
+
- If no issue exists, briefly explain the motivation
|
|
20
|
+
|
|
21
|
+
### How (Approach/Solution)
|
|
22
|
+
|
|
23
|
+
- Describe your implementation approach
|
|
24
|
+
- Explain any design decisions or trade-offs
|
|
25
|
+
- Include architectural changes if applicable
|
|
26
|
+
- Note any breaking changes prominently
|
|
27
|
+
|
|
28
|
+
## Supporting Details
|
|
29
|
+
|
|
30
|
+
### Testing and Quality
|
|
31
|
+
|
|
32
|
+
- What tests were added/modified
|
|
33
|
+
- How to manually verify the changes (if applicable)
|
|
34
|
+
- Any areas of concern or limitations
|
|
35
|
+
- Performance impact (if relevant)
|
|
36
|
+
|
|
37
|
+
### Dependencies and Risk
|
|
38
|
+
|
|
39
|
+
- New dependencies introduced (if any)
|
|
40
|
+
- Backward compatibility status
|
|
41
|
+
- Potential side effects
|
|
42
|
+
- Migration steps (if needed)
|
|
43
|
+
|
|
44
|
+
## Optional but Valuable
|
|
45
|
+
|
|
46
|
+
### Related Issues/PRs
|
|
47
|
+
|
|
48
|
+
- Link to dependent PRs or issues
|
|
49
|
+
- Note any follow-up work needed
|
|
50
|
+
|
|
51
|
+
### Screenshots/Examples (for UI changes)
|
|
52
|
+
|
|
53
|
+
- Before/after comparisons when visual changes are involved
|
|
54
|
+
|
|
55
|
+
### Reviewer Guidance
|
|
56
|
+
|
|
57
|
+
- Specific areas to focus on
|
|
58
|
+
- Questions for reviewers
|
|
59
|
+
- Deployment considerations
|
|
60
|
+
|
|
61
|
+
## Tone and Style Guidelines
|
|
62
|
+
|
|
63
|
+
- Be clear and concise -- reviewers scan quickly
|
|
64
|
+
- Use second person sparingly -- focus on what the code does, not what the reviewer should do
|
|
65
|
+
- Avoid jargon -- explain technical terms if non-obvious
|
|
66
|
+
- Use markdown formatting -- bullets, code blocks, headers for readability
|
|
67
|
+
- Be honest about limitations -- acknowledge trade-offs and known issues
|
|
68
|
+
- Assume reviewers are unfamiliar -- provide sufficient context
|
|
69
|
+
|
|
70
|
+
## What to Avoid
|
|
71
|
+
|
|
72
|
+
- Vague statements like "fix bug" or "update code"
|
|
73
|
+
- AI-generated summaries without human verification
|
|
74
|
+
- Large walls of text -- break into sections
|
|
75
|
+
- Repeating information from commit messages
|
|
76
|
+
- References to temporary branch names or internal jargon without context
|
|
77
|
+
|
|
78
|
+
## Example Structure
|
|
79
|
+
|
|
80
|
+
```markdown
|
|
81
|
+
## Description
|
|
82
|
+
Brief 1-2 sentence overview of the change.
|
|
83
|
+
|
|
84
|
+
## Why
|
|
85
|
+
Problem/context and reference to related issue (#123).
|
|
86
|
+
|
|
87
|
+
## How
|
|
88
|
+
Implementation approach and design decisions.
|
|
89
|
+
|
|
90
|
+
## Testing
|
|
91
|
+
How this was tested and verified.
|
|
92
|
+
|
|
93
|
+
## Risk Assessment
|
|
94
|
+
Any breaking changes, dependencies, or concerns.
|
|
95
|
+
```
|
|
@@ -123,9 +123,21 @@ def parse_bash_command_from_stdin() -> str:
|
|
|
123
123
|
return hook_event.get("tool_input", {}).get("command", "")
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
DRAFT_PR_INSTRUCTION = (
|
|
127
|
+
" Instead: (1) create a feature branch with `git checkout -b <descriptive-branch-name>`, "
|
|
128
|
+
"(2) commit your changes there, "
|
|
129
|
+
"(3) push with `git push -u origin <branch-name>`, "
|
|
130
|
+
"(4) create a draft PR with `gh pr create --draft`. "
|
|
131
|
+
"If you must commit to main, the user needs to approve explicitly."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
126
135
|
def build_denial_response(branch_name: str, repo_dir: str | None) -> dict:
|
|
127
136
|
location = f" in {repo_dir}" if repo_dir else ""
|
|
128
|
-
denial_reason =
|
|
137
|
+
denial_reason = (
|
|
138
|
+
f"BLOCKED: Direct commit to '{branch_name}'{location} is not allowed."
|
|
139
|
+
+ DRAFT_PR_INSTRUCTION
|
|
140
|
+
)
|
|
129
141
|
|
|
130
142
|
return {
|
|
131
143
|
"hookSpecificOutput": {
|
|
@@ -1,9 +1,67 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
2
3
|
import re
|
|
3
4
|
import sys
|
|
4
5
|
|
|
6
|
+
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
7
|
+
PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
REQUIRED_PR_SECTION_HEADERS = [
|
|
10
|
+
"description",
|
|
11
|
+
"why",
|
|
12
|
+
"how",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
MINIMUM_PR_BODY_LENGTH = 50
|
|
16
|
+
|
|
17
|
+
VAGUE_LANGUAGE_PATTERN = re.compile(
|
|
18
|
+
r'\b(fix(?:ed)? (?:bug|issue|it)|update(?:d)? code|minor changes|various (?:fixes|updates|improvements))\b',
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_body_from_command(command: str) -> str:
|
|
24
|
+
heredoc_match = re.search(r'--body\s+"\$\(cat <<', command)
|
|
25
|
+
if heredoc_match:
|
|
26
|
+
return command[heredoc_match.start():]
|
|
27
|
+
|
|
28
|
+
body_match = re.search(r'--body\s+"([^"]*)"', command) or re.search(r"--body\s+'([^']*)'", command)
|
|
29
|
+
if body_match:
|
|
30
|
+
return body_match.group(1)
|
|
31
|
+
|
|
32
|
+
short_flag_match = re.search(r'-b\s+"([^"]*)"', command) or re.search(r"-b\s+'([^']*)'", command)
|
|
33
|
+
if short_flag_match:
|
|
34
|
+
return short_flag_match.group(1)
|
|
35
|
+
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_pr_body(body: str) -> list[str]:
|
|
40
|
+
violations = []
|
|
41
|
+
body_lower = body.lower()
|
|
42
|
+
|
|
43
|
+
missing_required_sections = [
|
|
44
|
+
header for header in REQUIRED_PR_SECTION_HEADERS
|
|
45
|
+
if f"## {header}" not in body_lower and f"**{header}" not in body_lower
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
if missing_required_sections:
|
|
49
|
+
formatted_sections = ", ".join(f"'{each_section.title()}'" for each_section in missing_required_sections)
|
|
50
|
+
violations.append(f"Missing required section(s): {formatted_sections}")
|
|
51
|
+
|
|
52
|
+
stripped_body = re.sub(r'#.*', '', body).strip()
|
|
53
|
+
stripped_body = re.sub(r'\*\*.*?\*\*', '', stripped_body).strip()
|
|
54
|
+
if len(stripped_body) < MINIMUM_PR_BODY_LENGTH:
|
|
55
|
+
violations.append("PR body too short -- provide meaningful context for reviewers")
|
|
56
|
+
|
|
57
|
+
vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
|
|
58
|
+
if vague_matches:
|
|
59
|
+
violations.append(f"Vague language detected: {', '.join(vague_matches)} -- be specific about what changed and why")
|
|
60
|
+
|
|
61
|
+
return violations
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
7
65
|
try:
|
|
8
66
|
input_data = json.load(sys.stdin)
|
|
9
67
|
except json.JSONDecodeError:
|
|
@@ -19,62 +77,29 @@ def main():
|
|
|
19
77
|
|
|
20
78
|
is_pr_create = "gh pr create" in command and ("--body" in command or "-b " in command)
|
|
21
79
|
is_pr_edit = "gh pr edit" in command and "--body" in command
|
|
22
|
-
is_commit = re.search(r'git commit\b', command) and ("-m " in command or "-m\"" in command or "-m'" in command)
|
|
23
80
|
|
|
24
|
-
if not (is_pr_create or is_pr_edit
|
|
81
|
+
if not (is_pr_create or is_pr_edit):
|
|
25
82
|
sys.exit(0)
|
|
26
83
|
|
|
27
|
-
body =
|
|
28
|
-
if is_pr_create or is_pr_edit:
|
|
29
|
-
body_match = re.search(r'--body\s+"([^"]*)"', command) or re.search(r"--body\s+'([^']*)'", command)
|
|
30
|
-
if body_match:
|
|
31
|
-
body = body_match.group(1)
|
|
32
|
-
heredoc_match = re.search(r'--body\s+"\$\(cat <<', command)
|
|
33
|
-
if heredoc_match:
|
|
34
|
-
body = command[heredoc_match.start():]
|
|
35
|
-
|
|
36
|
-
if is_commit:
|
|
37
|
-
msg_match = re.search(r'-m\s+"([^"]*)"', command) or re.search(r"-m\s+'([^']*)'", command)
|
|
38
|
-
if msg_match:
|
|
39
|
-
body = msg_match.group(1)
|
|
40
|
-
heredoc_match = re.search(r'-m\s+"\$\(cat <<', command)
|
|
41
|
-
if heredoc_match:
|
|
42
|
-
body = command[heredoc_match.start():]
|
|
84
|
+
body = extract_body_from_command(command)
|
|
43
85
|
|
|
44
86
|
if not body:
|
|
45
87
|
sys.exit(0)
|
|
46
88
|
|
|
47
|
-
violations =
|
|
48
|
-
|
|
49
|
-
if is_pr_create or is_pr_edit:
|
|
50
|
-
if "## Summary" not in body and "## summary" not in body.lower():
|
|
51
|
-
violations.append("Missing '## Summary' section")
|
|
52
|
-
|
|
53
|
-
has_file_bold = bool(re.search(r'\*\*\w+\.\w+\*\*', body))
|
|
54
|
-
has_bullet_section = bool(re.search(r'###.*(?:test|config|fix)', body, re.IGNORECASE))
|
|
55
|
-
|
|
56
|
-
if not has_file_bold and not has_bullet_section:
|
|
57
|
-
violations.append("Production changes must be grouped by file with **filename** bold headers explaining WHY")
|
|
58
|
-
|
|
59
|
-
jargon_patterns = [
|
|
60
|
-
(r'\bDexie\b', "Dexie (say 'database' or 'local database')"),
|
|
61
|
-
(r'\bReact Query\b', "React Query (say 'cache' or 'data cache')"),
|
|
62
|
-
(r'\bsyncStatus\b', "syncStatus (describe the behavior, not the field)"),
|
|
63
|
-
(r'\blocalUpdatedAt\b', "localUpdatedAt (describe the behavior, not the field)"),
|
|
64
|
-
(r'\bpullStartedAt\b', "pullStartedAt (describe the behavior, not the field)"),
|
|
65
|
-
(r'\buseMutation\b', "useMutation (describe what it does for the user)"),
|
|
66
|
-
]
|
|
67
|
-
for pattern, name in jargon_patterns:
|
|
68
|
-
if re.search(pattern, body):
|
|
69
|
-
violations.append(f"Jargon detected: {name}")
|
|
89
|
+
violations = validate_pr_body(body)
|
|
70
90
|
|
|
71
91
|
if violations:
|
|
72
92
|
violation_list = "; ".join(violations)
|
|
93
|
+
pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
|
|
94
|
+
denial_reason = (
|
|
95
|
+
f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
|
|
96
|
+
f"Follow the PR description guide:{pr_guide_reference}"
|
|
97
|
+
)
|
|
73
98
|
result = {
|
|
74
99
|
"hookSpecificOutput": {
|
|
75
100
|
"hookEventName": "PreToolUse",
|
|
76
101
|
"permissionDecision": "deny",
|
|
77
|
-
"permissionDecisionReason":
|
|
102
|
+
"permissionDecisionReason": denial_reason,
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
print(json.dumps(result))
|
|
@@ -40,6 +40,7 @@ def get_question_from_stdin() -> str:
|
|
|
40
40
|
|
|
41
41
|
def main() -> None:
|
|
42
42
|
system = platform.system()
|
|
43
|
+
wsl_mode = is_wsl()
|
|
43
44
|
|
|
44
45
|
project_name = get_project_name()
|
|
45
46
|
question_text = get_question_from_stdin()
|
|
@@ -49,7 +50,7 @@ def main() -> None:
|
|
|
49
50
|
if system == "Windows":
|
|
50
51
|
sound_windows()
|
|
51
52
|
notify_windows(project_name, question_text)
|
|
52
|
-
elif
|
|
53
|
+
elif wsl_mode:
|
|
53
54
|
sound_wsl()
|
|
54
55
|
notify_wsl(project_name, question_text)
|
|
55
56
|
elif system == "Linux":
|
|
@@ -156,15 +156,18 @@ def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
|
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
def notify_linux() -> None:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
try:
|
|
160
|
+
subprocess.Popen(
|
|
161
|
+
[
|
|
162
|
+
"notify-send", "-t", LINUX_NOTIFICATION_TIMEOUT_MS,
|
|
163
|
+
"-u", "normal", "-i", "dialog-warning",
|
|
164
|
+
DEFAULT_LINUX_TOAST_TITLE, DEFAULT_LINUX_TOAST_MESSAGE,
|
|
165
|
+
],
|
|
166
|
+
stdout=subprocess.DEVNULL,
|
|
167
|
+
stderr=subprocess.DEVNULL
|
|
168
|
+
)
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return
|
|
168
171
|
|
|
169
172
|
|
|
170
173
|
def sound_windows() -> None:
|
|
@@ -11,12 +11,27 @@ This catches:
|
|
|
11
11
|
Works in both WSL and Windows for any Python project with a git root.
|
|
12
12
|
Project root is discovered via CLAUDE_PROJECT_ROOT env var or git rev-parse.
|
|
13
13
|
"""
|
|
14
|
+
import importlib
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import platform
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from pathlib import Path
|
|
21
|
+
from types import ModuleType
|
|
22
|
+
|
|
23
|
+
NOTIFICATION_UTILS_DIRECTORY = os.path.join(
|
|
24
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
|
|
25
|
+
)
|
|
26
|
+
sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_notification_utils() -> ModuleType | None:
|
|
30
|
+
try:
|
|
31
|
+
return importlib.import_module("notification_utils")
|
|
32
|
+
except ImportError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
20
35
|
|
|
21
36
|
IS_WINDOWS = platform.system() == "Windows"
|
|
22
37
|
|
|
@@ -104,6 +119,25 @@ def format_error_summary(all_error_lines: list[str]) -> str:
|
|
|
104
119
|
return error_summary
|
|
105
120
|
|
|
106
121
|
|
|
122
|
+
def send_block_notification(error_summary: str) -> None:
|
|
123
|
+
notification_module = load_notification_utils()
|
|
124
|
+
if notification_module is None:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
notification_title = "Mypy Type Errors"
|
|
128
|
+
notification_body = f"Write blocked: {error_summary[:200]}"
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
if notification_module.is_wsl():
|
|
132
|
+
notification_module.notify_wsl(notification_title, notification_body)
|
|
133
|
+
elif platform.system() == "Linux":
|
|
134
|
+
notification_module.notify_linux()
|
|
135
|
+
elif platform.system() == "Windows":
|
|
136
|
+
notification_module.notify_windows(notification_title, notification_body)
|
|
137
|
+
except (AttributeError, OSError):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
107
141
|
def build_block_response(error_summary: str) -> dict[str, str | dict[str, str]]:
|
|
108
142
|
return {
|
|
109
143
|
"decision": "block",
|
|
@@ -171,6 +205,7 @@ def main() -> None:
|
|
|
171
205
|
sys.exit(0)
|
|
172
206
|
|
|
173
207
|
error_summary = format_error_summary(all_error_lines)
|
|
208
|
+
send_block_notification(error_summary)
|
|
174
209
|
block_response = build_block_response(error_summary)
|
|
175
210
|
print(json.dumps(block_response))
|
|
176
211
|
sys.exit(0)
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
+
import importlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
6
|
+
import platform
|
|
5
7
|
import subprocess
|
|
6
8
|
import sys
|
|
7
9
|
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
NOTIFICATION_UTILS_DIRECTORY = os.path.join(
|
|
13
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
|
|
14
|
+
)
|
|
15
|
+
sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_notification_utils() -> ModuleType | None:
|
|
19
|
+
try:
|
|
20
|
+
return importlib.import_module("notification_utils")
|
|
21
|
+
except ImportError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def send_format_notification(file_path: str, formatter_name: str) -> None:
|
|
26
|
+
notification_module = load_notification_utils()
|
|
27
|
+
if notification_module is None:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
notification_title = "Auto-Formatter"
|
|
31
|
+
notification_body = f"{formatter_name} formatted: {Path(file_path).name}"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
if notification_module.is_wsl():
|
|
35
|
+
notification_module.notify_wsl(notification_title, notification_body)
|
|
36
|
+
elif platform.system() == "Linux":
|
|
37
|
+
notification_module.notify_linux()
|
|
38
|
+
elif platform.system() == "Windows":
|
|
39
|
+
notification_module.notify_windows(notification_title, notification_body)
|
|
40
|
+
except (AttributeError, OSError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
8
43
|
|
|
9
44
|
PYTHON_EXTENSIONS = {".py"}
|
|
10
45
|
JS_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs"}
|
|
@@ -89,6 +124,8 @@ def main() -> None:
|
|
|
89
124
|
try:
|
|
90
125
|
format_run = subprocess.run(each_formatter_command, capture_output=True, text=True, timeout=PYTHON_FORMAT_TIMEOUT_SECONDS)
|
|
91
126
|
if format_run.returncode == 0:
|
|
127
|
+
formatter_name = each_formatter_command[0] if each_formatter_command[0] != sys.executable else each_formatter_command[2]
|
|
128
|
+
send_format_notification(file_path, formatter_name)
|
|
92
129
|
break
|
|
93
130
|
except FileNotFoundError:
|
|
94
131
|
continue
|
|
@@ -98,12 +135,14 @@ def main() -> None:
|
|
|
98
135
|
if not has_prettier_config(file_path):
|
|
99
136
|
sys.exit(0)
|
|
100
137
|
try:
|
|
101
|
-
subprocess.run(
|
|
138
|
+
prettier_run = subprocess.run(
|
|
102
139
|
["npx", "--yes", "prettier", "--write", file_path],
|
|
103
140
|
capture_output=True,
|
|
104
141
|
text=True,
|
|
105
142
|
timeout=JS_FORMAT_TIMEOUT_SECONDS,
|
|
106
143
|
)
|
|
144
|
+
if prettier_run.returncode == 0:
|
|
145
|
+
send_format_notification(file_path, "prettier")
|
|
107
146
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
108
147
|
pass
|
|
109
148
|
|
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@ Scan the current repo root for these directories:
|
|
|
29
29
|
| `commands/` | Slash commands (.md) | `~/.claude/commands/` |
|
|
30
30
|
| `agents/` | Agent definitions (.md) | `~/.claude/agents/` |
|
|
31
31
|
| `skills/` | Skill packages (subdirs) | `~/.claude/skills/` |
|
|
32
|
-
| `hooks/` | Hook scripts + hooks.json | Merge into `~/.claude/settings.json` |
|
|
32
|
+
| `hooks/` | Hook scripts (+ optional hooks.json manifest) | Merge into `~/.claude/settings.json` |
|
|
33
33
|
|
|
34
34
|
Use Glob to find which directories exist and count files in each. Report findings to the user.
|
|
35
35
|
|
|
@@ -97,6 +97,10 @@ If hooks/hooks.json exists:
|
|
|
97
97
|
- Append new groups
|
|
98
98
|
- Write settings.json with `JSON.stringify(data, null, 4)`
|
|
99
99
|
|
|
100
|
+
Important runtime note:
|
|
101
|
+
- `~/.claude/settings.json` is the runtime source of truth for hooks.
|
|
102
|
+
- `hooks/hooks.json` is a packaging/install manifest input for merge workflows, not a runtime file Claude reads directly.
|
|
103
|
+
|
|
100
104
|
Print summary:
|
|
101
105
|
```
|
|
102
106
|
Installed <package-name>:
|
|
@@ -176,8 +180,8 @@ When ready to publish: `npm publish`
|
|
|
176
180
|
## Remember
|
|
177
181
|
|
|
178
182
|
- Zero external dependencies — only Node.js built-ins
|
|
179
|
-
- hooks.json in the repo stays canonical (python3 + ${CLAUDE_PLUGIN_ROOT})
|
|
180
|
-
- Rewriting happens only in the destination settings.json
|
|
183
|
+
- If present, hooks.json in the repo stays canonical (python3 + ${CLAUDE_PLUGIN_ROOT})
|
|
184
|
+
- Rewriting happens only in the destination settings.json (runtime source of truth)
|
|
181
185
|
- path.join() everywhere — never concatenate paths with `/` or `\`
|
|
182
186
|
- Idempotent: running twice produces the same result
|
|
183
187
|
- Log every file action so the user sees what changed
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
PreToolUse hook: blocks direct edits to Docker settings files.
|
|
4
|
-
Hooks must be added to the Windows settings.json instead.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
|
-
BLOCKED_PATHS = [
|
|
11
|
-
"settings-docker.json",
|
|
12
|
-
"settings-docker",
|
|
13
|
-
"docker/settings-docker.json",
|
|
14
|
-
".claude/docker/settings-docker.json",
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def main() -> None:
|
|
19
|
-
try:
|
|
20
|
-
stdin_data = sys.stdin.read()
|
|
21
|
-
hook_input = json.loads(stdin_data)
|
|
22
|
-
tool_input = hook_input.get("tool_input", {})
|
|
23
|
-
file_path = tool_input.get("file_path", "")
|
|
24
|
-
|
|
25
|
-
for blocked in BLOCKED_PATHS:
|
|
26
|
-
if file_path.endswith(blocked):
|
|
27
|
-
message = "BLOCKED: Docker settings edit denied. Edit your user settings.json instead."
|
|
28
|
-
result = {
|
|
29
|
-
"hookSpecificOutput": {
|
|
30
|
-
"hookEventName": "PreToolUse",
|
|
31
|
-
"permissionDecision": "deny",
|
|
32
|
-
"permissionDecisionReason": message
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
print(json.dumps(result))
|
|
36
|
-
sys.exit(0)
|
|
37
|
-
except SystemExit:
|
|
38
|
-
raise
|
|
39
|
-
except Exception:
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if __name__ == "__main__":
|
|
44
|
-
main()
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse:Task hook — suggests team orchestration for parallel Task spawning.
|
|
3
|
-
|
|
4
|
-
Detects parallel Task/Agent calls without team_name and injects a suggestion
|
|
5
|
-
into the conversation context. Does NOT block — the tool call proceeds.
|
|
6
|
-
|
|
7
|
-
Uses atomic file creation (O_CREAT | O_EXCL) to detect concurrent calls.
|
|
8
|
-
Lock auto-expires after 3 seconds to avoid false positives on sequential calls.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import sys
|
|
14
|
-
import tempfile
|
|
15
|
-
import time
|
|
16
|
-
|
|
17
|
-
LOCK_FILE = os.path.join(tempfile.gettempdir(), "claude-parallel-task-guard.lock")
|
|
18
|
-
LOCK_MAX_AGE_SECONDS = 3
|
|
19
|
-
|
|
20
|
-
SUGGESTION_MESSAGE = (
|
|
21
|
-
"SUGGESTION: Multiple parallel agents detected without team orchestration. "
|
|
22
|
-
"Consider using TeamCreate + team_name for better coordination, "
|
|
23
|
-
"progress tracking, and file ownership management. "
|
|
24
|
-
"This is optional — parallel agents will proceed without it."
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def main() -> None:
|
|
29
|
-
try:
|
|
30
|
-
input_data = json.load(sys.stdin)
|
|
31
|
-
except json.JSONDecodeError:
|
|
32
|
-
sys.exit(0)
|
|
33
|
-
|
|
34
|
-
tool_name = input_data.get("tool_name", "")
|
|
35
|
-
tool_input = input_data.get("tool_input", {})
|
|
36
|
-
|
|
37
|
-
if tool_name not in ("Task", "Agent"):
|
|
38
|
-
sys.exit(0)
|
|
39
|
-
|
|
40
|
-
# Team-orchestrated tasks — no suggestion needed
|
|
41
|
-
if tool_input.get("team_name"):
|
|
42
|
-
sys.exit(0)
|
|
43
|
-
|
|
44
|
-
# Clean stale locks (previous turn's lock that wasn't cleaned up)
|
|
45
|
-
try:
|
|
46
|
-
if os.path.exists(LOCK_FILE):
|
|
47
|
-
lock_age = time.time() - os.path.getmtime(LOCK_FILE)
|
|
48
|
-
if lock_age > LOCK_MAX_AGE_SECONDS:
|
|
49
|
-
os.unlink(LOCK_FILE)
|
|
50
|
-
except OSError:
|
|
51
|
-
pass # Race with another process cleaning — fine
|
|
52
|
-
|
|
53
|
-
# Atomic create: only one concurrent caller wins
|
|
54
|
-
try:
|
|
55
|
-
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
56
|
-
os.write(fd, str(time.time()).encode())
|
|
57
|
-
os.close(fd)
|
|
58
|
-
# First Task in this turn — no suggestion
|
|
59
|
-
sys.exit(0)
|
|
60
|
-
except FileExistsError:
|
|
61
|
-
pass # Another Task already holds the lock
|
|
62
|
-
|
|
63
|
-
# Second+ parallel Task without team → suggest (not block)
|
|
64
|
-
print(SUGGESTION_MESSAGE, file=sys.stderr)
|
|
65
|
-
sys.exit(0)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if __name__ == "__main__":
|
|
69
|
-
main()
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Block pyautogui mousewheel scroll usage - guide Claude to use pynput instead.
|
|
4
|
-
|
|
5
|
-
pyautogui's scroll implementation uses incorrect delta values with SendInput API.
|
|
6
|
-
pynput uses the same SendInput API but sends correct delta values.
|
|
7
|
-
"""
|
|
8
|
-
import json
|
|
9
|
-
import re
|
|
10
|
-
import sys
|
|
11
|
-
|
|
12
|
-
PYAUTOGUI_SCROLL_PATTERNS = [
|
|
13
|
-
r'pyautogui\.scroll\s*\(',
|
|
14
|
-
r'pyautogui\.hscroll\s*\(',
|
|
15
|
-
r'pyautogui\.vscroll\s*\(',
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
COMPILED_PATTERNS = [re.compile(pattern) for pattern in PYAUTOGUI_SCROLL_PATTERNS]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def check_for_pyautogui_scroll(content: str) -> list[str]:
|
|
22
|
-
"""Check for pyautogui scroll function usage."""
|
|
23
|
-
violations = []
|
|
24
|
-
lines = content.split('\n')
|
|
25
|
-
|
|
26
|
-
for line_num, line in enumerate(lines, 1):
|
|
27
|
-
for pattern in COMPILED_PATTERNS:
|
|
28
|
-
if pattern.search(line):
|
|
29
|
-
violations.append(f"Line {line_num}: {line.strip()}")
|
|
30
|
-
break
|
|
31
|
-
|
|
32
|
-
return violations
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def main() -> None:
|
|
36
|
-
try:
|
|
37
|
-
input_data = json.load(sys.stdin)
|
|
38
|
-
except json.JSONDecodeError:
|
|
39
|
-
sys.exit(0)
|
|
40
|
-
|
|
41
|
-
tool_input = input_data.get("tool_input", {})
|
|
42
|
-
file_path = tool_input.get("file_path", "")
|
|
43
|
-
|
|
44
|
-
if not file_path:
|
|
45
|
-
sys.exit(0)
|
|
46
|
-
|
|
47
|
-
# Only check Python files
|
|
48
|
-
if not file_path.endswith('.py'):
|
|
49
|
-
sys.exit(0)
|
|
50
|
-
|
|
51
|
-
content = tool_input.get("content", "") or tool_input.get("new_string", "")
|
|
52
|
-
|
|
53
|
-
if not content:
|
|
54
|
-
sys.exit(0)
|
|
55
|
-
|
|
56
|
-
violations = check_for_pyautogui_scroll(content)
|
|
57
|
-
|
|
58
|
-
if violations:
|
|
59
|
-
violation_list = "\n".join(f" • {v}" for v in violations[:5])
|
|
60
|
-
result = {
|
|
61
|
-
"hookSpecificOutput": {
|
|
62
|
-
"hookEventName": "PreToolUse",
|
|
63
|
-
"permissionDecision": "deny",
|
|
64
|
-
"permissionDecisionReason": f"BLOCKED: pyautogui scroll() is broken on Windows (incorrect delta values). Use pynput instead: from pynput.mouse import Controller; mouse = Controller(); mouse.scroll(0, -3) for scrolling down 3 clicks."
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
print(json.dumps(result))
|
|
68
|
-
sys.stdout.flush()
|
|
69
|
-
|
|
70
|
-
sys.exit(0)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if __name__ == "__main__":
|
|
74
|
-
main()
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import json
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
BULK_UPDATE_KEYWORDS = ["update all", "replace all", "change all", "fix all", "rename all"]
|
|
6
|
-
|
|
7
|
-
BULK_UPDATE_REMINDER = "BULK UPDATE DETECTED: Use a Python script with --preview/--apply instead of line-by-line edits."
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def main() -> None:
|
|
11
|
-
try:
|
|
12
|
-
input_data = json.load(sys.stdin)
|
|
13
|
-
except json.JSONDecodeError:
|
|
14
|
-
sys.exit(0)
|
|
15
|
-
|
|
16
|
-
prompt = input_data.get("prompt", "")
|
|
17
|
-
|
|
18
|
-
if not prompt:
|
|
19
|
-
sys.exit(0)
|
|
20
|
-
|
|
21
|
-
message_lower = prompt.lower()
|
|
22
|
-
|
|
23
|
-
if any(keyword in message_lower for keyword in BULK_UPDATE_KEYWORDS):
|
|
24
|
-
print(BULK_UPDATE_REMINDER)
|
|
25
|
-
|
|
26
|
-
sys.exit(0)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if __name__ == "__main__":
|
|
30
|
-
main()
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
UserPromptSubmit hook that detects code-related requests and injects CODE_RULES.md reminder.
|
|
4
|
-
Triggers on keywords indicating code writing, planning, or implementation.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import re
|
|
9
|
-
import sys
|
|
10
|
-
|
|
11
|
-
CODE_KEYWORDS = [
|
|
12
|
-
# Creation verbs
|
|
13
|
-
r'\b(write|create|implement|add|build|make|generate|develop|setup|scaffold|bootstrap|initialize|init|compose|construct|define|declare|register|wire|connect|integrate|introduce)\b',
|
|
14
|
-
# Code nouns
|
|
15
|
-
r'\b(code|function|class|method|script|module|component|hook|test|spec|api|endpoint|route|handler|service|util|helper|factory|interface|type|enum|constant|variable|parameter|argument|logic|algorithm|feature|library|package|dependency|plugin|extension|widget|element|node|token|parser|serializer|validator|formatter|linter|compiler|transpiler|bundler|loader|resolver|provider|consumer|producer|subscriber|publisher|emitter|dispatcher|reducer|selector|adapter|wrapper|decorator|mixin|trait|protocol|abstract|generic|iterator|generator|coroutine|fiber|thread|process|worker|job|task|queue|stack|buffer|stream|pipe|socket|channel|signal|slot|observer|mediator|strategy|command|visitor|singleton|repository|gateway|mapper|transformer|converter|encoder|decoder|interceptor|guard|filter|middleware|pipeline|chain|proxy|facade|bridge|flyweight|memento|prototype)\b',
|
|
16
|
-
# Modification verbs
|
|
17
|
-
r'\b(fix|update|refactor|modify|change|edit|rewrite|improve|enhance|optimize|debug|patch|correct|adjust|tweak|rework|revise|extend|expand|rename|move|extract|inline|split|merge|combine|consolidate|simplify|clean|cleanup|reorganize|restructure|decouple|encapsulate|abstract|generalize|specialize|upgrade|downgrade|migrate|convert|transform|adapt|port|backport)\b',
|
|
18
|
-
# Deletion/removal verbs
|
|
19
|
-
r'\b(delete|remove|drop|deprecate|disable|deactivate|unregister|detach|disconnect|unbind|unsubscribe|uninstall|prune|trim|strip|purge|clear|reset|destroy|dispose|release|free|deallocate)\b',
|
|
20
|
-
# Planning verbs
|
|
21
|
-
r'\b(plan|design|structure|architect|outline|draft|sketch|propose|suggest|approach|strategy|solution|how would|how should|how do|how can|how to|what if|where should|when should|why does|why is|could we|should we|can we|let.s|need to|want to|going to)\b',
|
|
22
|
-
# Review/analysis verbs
|
|
23
|
-
r'\b(review|check|analyze|audit|inspect|examine|validate|verify|assess|evaluate|trace|profile|benchmark|measure|monitor|diagnose|troubleshoot|investigate|identify|detect|discover|locate|find the bug|root cause)\b',
|
|
24
|
-
# Testing verbs
|
|
25
|
-
r'\b(test|run tests|unit test|integration test|e2e test|end.to.end|assert|expect|mock|stub|spy|fake|fixture|setup|teardown|arrange|act|coverage|regression|smoke test|snapshot|parameterize)\b',
|
|
26
|
-
# File types
|
|
27
|
-
r'\.(py|js|ts|tsx|jsx|css|scss|less|html|json|yaml|yml|toml|ini|cfg|sql|sh|bash|zsh|vue|svelte|go|rs|java|kt|swift|rb|php|c|cpp|h|hpp|cs|fs|ex|exs|erl|hs|ml|clj|scala|groovy|dart|lua|r|jl|nim|zig|wasm|graphql|proto|tf|hcl)\b',
|
|
28
|
-
# Programming concepts
|
|
29
|
-
r'\b(loop|condition|if statement|switch|try|catch|exception|error handling|async|await|promise|callback|event|listener|state|props|render|return|import|export|inherit|extend|override|decorator|middleware|migration|schema|model|view|controller|template|query|mutation|subscription|context|scope|closure|binding|reference|pointer|memory|allocation|garbage collection|concurrency|parallelism|synchronization|deadlock|race condition|mutex|semaphore|lock|atomic|transaction|rollback|commit|index|constraint|foreign key|primary key|join|aggregate|subquery|cursor|trigger|stored procedure|materialized view)\b',
|
|
30
|
-
# DevOps/infrastructure
|
|
31
|
-
r'\b(deploy|release|publish|ship|rollout|rollback|ci|cd|pipeline|docker|container|kubernetes|k8s|terraform|ansible|nginx|apache|server|cluster|replica|shard|partition|load balancer|proxy|cdn|ssl|tls|certificate|dns|domain|cors|csp|firewall|vpc|subnet|security group|iam|role|policy|secret|vault|env var|environment variable|configuration|config file|dotenv)\b',
|
|
32
|
-
# Database/data
|
|
33
|
-
r'\b(database|db|sql|nosql|mongo|postgres|mysql|sqlite|redis|elasticsearch|dynamodb|cassandra|orm|queryset|recordset|dataset|dataframe|csv|parquet|avro|protobuf|graphql|rest|grpc|websocket|sse|webhook|polling|pagination|cursor|offset|limit|batch|bulk|upsert|crud)\b',
|
|
34
|
-
# Sample/example requests
|
|
35
|
-
r'\b(example|sample|snippet|demo|prototype|proof of concept|poc|skeleton|boilerplate|starter|template|scaffold|seed|initial|baseline|reference implementation|minimal|basic|simple|quick|small)\b',
|
|
36
|
-
# Common tool/framework references
|
|
37
|
-
r'\b(django|flask|fastapi|express|next|react|vue|angular|svelte|tailwind|bootstrap|jest|pytest|mocha|cypress|playwright|selenium|webpack|vite|rollup|esbuild|npm|yarn|pnpm|pip|poetry|cargo|gradle|maven|cmake|bazel|make|dockerfile|compose|github|gitlab|bitbucket|jira|confluence)\b',
|
|
38
|
-
]
|
|
39
|
-
|
|
40
|
-
CONDENSED_RULES = """
|
|
41
|
-
<code-rules-reminder>
|
|
42
|
-
## MANDATORY CODE RULES - APPLY TO ALL CODE (samples, plans, implementations)
|
|
43
|
-
|
|
44
|
-
1. **NO COMMENTS** - Self-documenting names only
|
|
45
|
-
- BAD: `d = 0.5 # delay` -> GOOD: `delay_between_retries_seconds = 0.5`
|
|
46
|
-
|
|
47
|
-
2. **NO MAGIC VALUES** - Everything named and in config
|
|
48
|
-
- BAD: `if score > 0.8:` -> GOOD: `if score > MINIMUM_CONFIDENCE_THRESHOLD:`
|
|
49
|
-
|
|
50
|
-
3. **NO ABBREVIATIONS** - Full words always
|
|
51
|
-
- BAD: `ctx`, `cfg`, `msg`, `btn` -> GOOD: `context`, `configuration`, `message`, `button`
|
|
52
|
-
|
|
53
|
-
4. **COMPLETE TYPE HINTS** - All parameters and returns typed, no `Any`
|
|
54
|
-
|
|
55
|
-
5. **CENTRALIZED CONFIG** - Constants in config/, imported everywhere
|
|
56
|
-
|
|
57
|
-
6. **SEARCH BEFORE CREATE** - Use everything-search skill before defining constants
|
|
58
|
-
|
|
59
|
-
7. **ALL IMPORTS SHOWN** - Every code block includes its imports
|
|
60
|
-
|
|
61
|
-
8. **SELF-CONTAINED COMPONENTS** - Components own their modals/toasts/state
|
|
62
|
-
|
|
63
|
-
CHECKLIST before writing ANY code:
|
|
64
|
-
[ ] No comments (names explain everything)
|
|
65
|
-
[ ] No magic values (all named constants)
|
|
66
|
-
[ ] No abbreviations (full words)
|
|
67
|
-
[ ] Complete types (all params + returns)
|
|
68
|
-
[ ] Imports shown
|
|
69
|
-
|
|
70
|
-
SCOPE: These rules apply to code you WRITE or MODIFY. Do NOT fix violations in untouched code unless explicitly instructed.
|
|
71
|
-
</code-rules-reminder>
|
|
72
|
-
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def main() -> None:
|
|
76
|
-
try:
|
|
77
|
-
hook_input = json.load(sys.stdin)
|
|
78
|
-
except json.JSONDecodeError:
|
|
79
|
-
sys.exit(0)
|
|
80
|
-
|
|
81
|
-
prompt = hook_input.get("prompt", "")
|
|
82
|
-
|
|
83
|
-
if not prompt:
|
|
84
|
-
sys.exit(0)
|
|
85
|
-
|
|
86
|
-
message_lower = prompt.lower()
|
|
87
|
-
|
|
88
|
-
for pattern in CODE_KEYWORDS:
|
|
89
|
-
if re.search(pattern, message_lower, re.IGNORECASE):
|
|
90
|
-
print(CONDENSED_RULES)
|
|
91
|
-
sys.exit(0)
|
|
92
|
-
|
|
93
|
-
sys.exit(0)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if __name__ == "__main__":
|
|
97
|
-
main()
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
7
|
-
CODE_RULES_PATH = os.path.join(PLUGIN_ROOT, "docs", "CODE_RULES.md")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def load_code_rules() -> str:
|
|
11
|
-
try:
|
|
12
|
-
with open(CODE_RULES_PATH, encoding="utf-8") as code_rules_file:
|
|
13
|
-
return code_rules_file.read()
|
|
14
|
-
except (FileNotFoundError, OSError):
|
|
15
|
-
return ""
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def main() -> None:
|
|
19
|
-
try:
|
|
20
|
-
json.load(sys.stdin)
|
|
21
|
-
except json.JSONDecodeError:
|
|
22
|
-
sys.exit(0)
|
|
23
|
-
|
|
24
|
-
code_rules_content = load_code_rules()
|
|
25
|
-
if not code_rules_content:
|
|
26
|
-
sys.exit(0)
|
|
27
|
-
|
|
28
|
-
reinject_payload = {
|
|
29
|
-
"hookSpecificOutput": {
|
|
30
|
-
"hookEventName": "SessionStart",
|
|
31
|
-
"additionalContext": code_rules_content,
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
print(json.dumps(reinject_payload))
|
|
35
|
-
sys.exit(0)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if __name__ == "__main__":
|
|
39
|
-
main()
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
UserPromptSubmit hook that detects "hook" mentions and injects
|
|
4
|
-
context about our current hook structure and how to add new hooks.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
TRIGGER_PHRASES = [
|
|
12
|
-
"hook",
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
EXCLUDE_PHRASES = [
|
|
16
|
-
"react hook",
|
|
17
|
-
"usehook",
|
|
18
|
-
"usestate",
|
|
19
|
-
"useeffect",
|
|
20
|
-
"custom hook",
|
|
21
|
-
"git hook",
|
|
22
|
-
"pre-commit hook",
|
|
23
|
-
"webhook",
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
CONTEXT = """
|
|
27
|
-
<hook-structure-context>
|
|
28
|
-
## Claude Code Hook System
|
|
29
|
-
|
|
30
|
-
### Architecture
|
|
31
|
-
- **Runner pattern**: `run-hook-wrapper.js` (Node.js) -> `run-hook.py` (Python) -> individual hook
|
|
32
|
-
- **Hook directory**: `hooks/`
|
|
33
|
-
|
|
34
|
-
### Hook Organization (subfolder structure)
|
|
35
|
-
```
|
|
36
|
-
hooks/
|
|
37
|
-
|-- rewrite-plugin-paths.py
|
|
38
|
-
|-- session/
|
|
39
|
-
| |-- compact-context-reinject.py
|
|
40
|
-
| |-- plugin-data-dir-cleanup.py
|
|
41
|
-
| +-- hook-structure-context.py
|
|
42
|
-
|-- notification/
|
|
43
|
-
| |-- attention-needed-notify.py
|
|
44
|
-
| |-- claude-notification-handler.py
|
|
45
|
-
| +-- notification_utils.py
|
|
46
|
-
|-- advisory/
|
|
47
|
-
| |-- refactor-guard.py
|
|
48
|
-
| +-- migration-safety-advisor.py
|
|
49
|
-
|-- validation/
|
|
50
|
-
| |-- code-style-validator.py
|
|
51
|
-
| |-- hook-format-validator.py
|
|
52
|
-
| |-- mypy_validator.py
|
|
53
|
-
| +-- e2e-test-validator.py
|
|
54
|
-
|-- lifecycle/
|
|
55
|
-
| |-- config-change-guard.py
|
|
56
|
-
| +-- session-end-cleanup.py
|
|
57
|
-
|-- blocking/
|
|
58
|
-
| |-- pyautogui-scroll-blocker.py
|
|
59
|
-
| |-- sensitive-file-protector.py
|
|
60
|
-
| |-- write-existing-file-blocker.py
|
|
61
|
-
| +-- destructive-command-blocker.py
|
|
62
|
-
|-- git-hooks/
|
|
63
|
-
| +-- post-commit.py
|
|
64
|
-
|-- github-action/
|
|
65
|
-
| +-- test_workflow.py
|
|
66
|
-
+-- validators/
|
|
67
|
-
|-- (validation check modules)
|
|
68
|
-
+-- test_files/
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### Event Types
|
|
72
|
-
| Event | When | Input (stdin JSON) | Can Block? |
|
|
73
|
-
|-------|------|-------------------|------------|
|
|
74
|
-
| SessionStart | Session begins | `{}` | No |
|
|
75
|
-
| UserPromptSubmit | User sends message | `{"prompt": "..."}` | No (advisory) |
|
|
76
|
-
| PreToolUse | Before tool execution | `{"tool_name": "...", "tool_input": {...}}` | YES (exit 2) |
|
|
77
|
-
| PostToolUse | After tool execution | `{"tool_name": "...", "tool_input": {...}, "tool_output": "..."}` | No |
|
|
78
|
-
| SubagentStop | Agent completes | `{"agent_type": "...", ...}` | No |
|
|
79
|
-
| Stop | Session ends | `{}` | No |
|
|
80
|
-
|
|
81
|
-
### How to Add a New Hook
|
|
82
|
-
|
|
83
|
-
1. **Create the hook file** in the appropriate subfolder:
|
|
84
|
-
```python
|
|
85
|
-
#!/usr/bin/env python3
|
|
86
|
-
import json
|
|
87
|
-
import sys
|
|
88
|
-
|
|
89
|
-
hook_input = json.load(sys.stdin)
|
|
90
|
-
print("Context to inject")
|
|
91
|
-
sys.exit(0)
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
2. **Register in settings.json** under the correct event type:
|
|
95
|
-
```json
|
|
96
|
-
{
|
|
97
|
-
"type": "command",
|
|
98
|
-
"command": "node -e \\"process.argv.splice(1,0,'_');require(require('os').homedir()+'/.claude/hooks/run-hook-wrapper.js')\\" \\"session/your-hook.py\\"",
|
|
99
|
-
"timeout": 15000
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
3. **Key rules**:
|
|
104
|
-
- Always use the `run-hook-wrapper.js` pattern (cross-platform)
|
|
105
|
-
- Set explicit timeouts (10000-30000ms)
|
|
106
|
-
- PreToolUse hooks use `matcher` to scope which tools they fire on
|
|
107
|
-
- UserPromptSubmit hooks match on `prompt` content
|
|
108
|
-
- Print output = context injected into Claude's conversation
|
|
109
|
-
- Advisory hooks exit 0; blocking hooks exit 2 with `hookSpecificOutput.permissionDecision`
|
|
110
|
-
</hook-structure-context>
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def main() -> None:
|
|
115
|
-
try:
|
|
116
|
-
hook_input = json.load(sys.stdin)
|
|
117
|
-
except json.JSONDecodeError:
|
|
118
|
-
sys.exit(0)
|
|
119
|
-
|
|
120
|
-
prompt = hook_input.get("prompt", "")
|
|
121
|
-
|
|
122
|
-
if not prompt:
|
|
123
|
-
sys.exit(0)
|
|
124
|
-
|
|
125
|
-
message_lower = prompt.lower()
|
|
126
|
-
|
|
127
|
-
for exclude in EXCLUDE_PHRASES:
|
|
128
|
-
if exclude in message_lower:
|
|
129
|
-
sys.exit(0)
|
|
130
|
-
|
|
131
|
-
for phrase in TRIGGER_PHRASES:
|
|
132
|
-
if phrase in message_lower:
|
|
133
|
-
print(CONTEXT)
|
|
134
|
-
sys.exit(0)
|
|
135
|
-
|
|
136
|
-
sys.exit(0)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if __name__ == "__main__":
|
|
140
|
-
main()
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Code style validator - checks for common style issues.
|
|
4
|
-
|
|
5
|
-
- 4-space indentation (not tabs, not 2 spaces)
|
|
6
|
-
- Single newlines between functions (not double)
|
|
7
|
-
- Single newlines between class methods
|
|
8
|
-
"""
|
|
9
|
-
import json
|
|
10
|
-
import re
|
|
11
|
-
import sys
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def check_indentation(content: str) -> list[str]:
|
|
15
|
-
"""Check for non-4-space indentation."""
|
|
16
|
-
issues = []
|
|
17
|
-
lines = content.split('\n')
|
|
18
|
-
|
|
19
|
-
for line_num, line in enumerate(lines, 1):
|
|
20
|
-
if not line or not line[0].isspace():
|
|
21
|
-
continue
|
|
22
|
-
|
|
23
|
-
# Check for tabs
|
|
24
|
-
if '\t' in line:
|
|
25
|
-
issues.append(f"Line {line_num}: Tab indentation - use 4 spaces")
|
|
26
|
-
continue
|
|
27
|
-
|
|
28
|
-
# Get leading spaces
|
|
29
|
-
stripped = line.lstrip(' ')
|
|
30
|
-
indent = len(line) - len(stripped)
|
|
31
|
-
|
|
32
|
-
# Check if indent is multiple of 4
|
|
33
|
-
if indent > 0 and indent % 4 != 0:
|
|
34
|
-
issues.append(f"Line {line_num}: {indent}-space indent - use 4 spaces")
|
|
35
|
-
|
|
36
|
-
return issues[:5] # Limit to first 5
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def check_function_spacing(content: str) -> list[str]:
|
|
40
|
-
"""Check for excessive blank lines between code blocks.
|
|
41
|
-
|
|
42
|
-
Detects 2+ consecutive blank lines anywhere in the file, plus validates
|
|
43
|
-
correct spacing before function/method/class definitions.
|
|
44
|
-
"""
|
|
45
|
-
issues = []
|
|
46
|
-
lines = content.split('\n')
|
|
47
|
-
|
|
48
|
-
func_pattern = re.compile(r'^(\s*)(async\s+)?def\s+\w+')
|
|
49
|
-
class_pattern = re.compile(r'^class\s+\w+')
|
|
50
|
-
|
|
51
|
-
consecutive_blank_count = 0
|
|
52
|
-
blank_run_start_line = 0
|
|
53
|
-
prev_was_code = False
|
|
54
|
-
|
|
55
|
-
for line_num, line in enumerate(lines, 1):
|
|
56
|
-
stripped = line.strip()
|
|
57
|
-
|
|
58
|
-
if not stripped:
|
|
59
|
-
if consecutive_blank_count == 0:
|
|
60
|
-
blank_run_start_line = line_num
|
|
61
|
-
consecutive_blank_count += 1
|
|
62
|
-
continue
|
|
63
|
-
|
|
64
|
-
if consecutive_blank_count >= 3:
|
|
65
|
-
issues.append(f"Line {blank_run_start_line}: {consecutive_blank_count} consecutive blank lines - max 2 allowed")
|
|
66
|
-
|
|
67
|
-
func_match = func_pattern.match(line)
|
|
68
|
-
class_match = class_pattern.match(line)
|
|
69
|
-
|
|
70
|
-
if func_match and prev_was_code:
|
|
71
|
-
indent = len(func_match.group(1)) if func_match.group(1) else 0
|
|
72
|
-
|
|
73
|
-
if indent == 0:
|
|
74
|
-
if consecutive_blank_count != 2:
|
|
75
|
-
issues.append(f"Line {line_num}: Top-level function needs 2 blank lines above (has {consecutive_blank_count})")
|
|
76
|
-
else:
|
|
77
|
-
if consecutive_blank_count != 1:
|
|
78
|
-
issues.append(f"Line {line_num}: Method needs 1 blank line above (has {consecutive_blank_count})")
|
|
79
|
-
|
|
80
|
-
elif class_match and prev_was_code:
|
|
81
|
-
if consecutive_blank_count != 2:
|
|
82
|
-
issues.append(f"Line {line_num}: Class needs 2 blank lines above (has {consecutive_blank_count})")
|
|
83
|
-
|
|
84
|
-
consecutive_blank_count = 0
|
|
85
|
-
prev_was_code = not stripped.startswith('#') and not stripped.startswith('@')
|
|
86
|
-
|
|
87
|
-
return issues[:5]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def main() -> None:
|
|
91
|
-
try:
|
|
92
|
-
input_data = json.load(sys.stdin)
|
|
93
|
-
except json.JSONDecodeError:
|
|
94
|
-
sys.exit(0)
|
|
95
|
-
|
|
96
|
-
tool_input = input_data.get("tool_input", {})
|
|
97
|
-
file_path = tool_input.get("file_path", "")
|
|
98
|
-
|
|
99
|
-
if not file_path:
|
|
100
|
-
sys.exit(0)
|
|
101
|
-
|
|
102
|
-
# Only check Python files
|
|
103
|
-
if not file_path.endswith('.py'):
|
|
104
|
-
sys.exit(0)
|
|
105
|
-
|
|
106
|
-
# Skip test files (more lenient)
|
|
107
|
-
if 'test' in file_path.lower() or 'conftest' in file_path.lower():
|
|
108
|
-
sys.exit(0)
|
|
109
|
-
|
|
110
|
-
tool_name = input_data.get("tool_name", "")
|
|
111
|
-
content = tool_input.get("content", "") or tool_input.get("new_string", "")
|
|
112
|
-
|
|
113
|
-
if not content:
|
|
114
|
-
sys.exit(0)
|
|
115
|
-
|
|
116
|
-
if tool_name == "Write":
|
|
117
|
-
try:
|
|
118
|
-
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
119
|
-
existing_content = existing_file.read()
|
|
120
|
-
if existing_content:
|
|
121
|
-
sys.exit(0)
|
|
122
|
-
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
123
|
-
pass
|
|
124
|
-
|
|
125
|
-
issues = []
|
|
126
|
-
issues.extend(check_indentation(content))
|
|
127
|
-
issues.extend(check_function_spacing(content))
|
|
128
|
-
|
|
129
|
-
if issues:
|
|
130
|
-
issue_list = "; ".join(issues)
|
|
131
|
-
result = {
|
|
132
|
-
"hookSpecificOutput": {
|
|
133
|
-
"hookEventName": "PreToolUse",
|
|
134
|
-
"permissionDecision": "ask",
|
|
135
|
-
"permissionDecisionReason": f"[Code Style] {len(issues)} issue(s): {issue_list}. Fix or proceed?"
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
print(json.dumps(result))
|
|
139
|
-
sys.stdout.flush()
|
|
140
|
-
|
|
141
|
-
sys.exit(0)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if __name__ == "__main__":
|
|
145
|
-
main()
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Validate E2E test consistency between online/offline specs.
|
|
4
|
-
|
|
5
|
-
Two checks:
|
|
6
|
-
1. Naming: offline tests must mirror online test names with " offline" suffix.
|
|
7
|
-
2. Coverage: when a new online e2e test file is written, a corresponding
|
|
8
|
-
offline equivalent must exist. Blocks if missing.
|
|
9
|
-
|
|
10
|
-
Triggered as PostToolUse hook when editing spec files.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import json
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import sys
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
E2E_TEST_DIRECTORY = "frontend/tests/e2e"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def extract_test_names(file_path: Path) -> set[str]:
|
|
24
|
-
"""Extract test names from spec file."""
|
|
25
|
-
content = file_path.read_text()
|
|
26
|
-
pattern = r"test\(['\"]([^'\"]+)['\"]"
|
|
27
|
-
return set(re.findall(pattern, content))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def validate_e2e_naming(project_root: Path) -> list[str]:
|
|
31
|
-
"""Return list of naming violations.
|
|
32
|
-
|
|
33
|
-
Only validates tests that follow the naming convention (end with " offline").
|
|
34
|
-
Legacy tests without the suffix are ignored - they may intentionally differ.
|
|
35
|
-
"""
|
|
36
|
-
online = project_root / E2E_TEST_DIRECTORY / "online.spec.ts"
|
|
37
|
-
offline = project_root / E2E_TEST_DIRECTORY / "offline.spec.ts"
|
|
38
|
-
|
|
39
|
-
if not online.exists() or not offline.exists():
|
|
40
|
-
return []
|
|
41
|
-
|
|
42
|
-
online_tests = extract_test_names(online)
|
|
43
|
-
offline_tests = extract_test_names(offline)
|
|
44
|
-
|
|
45
|
-
violations = []
|
|
46
|
-
|
|
47
|
-
for test in offline_tests:
|
|
48
|
-
if not test.endswith(" offline"):
|
|
49
|
-
continue
|
|
50
|
-
|
|
51
|
-
online_name = test.removesuffix(" offline")
|
|
52
|
-
if online_name not in online_tests:
|
|
53
|
-
violations.append(f"No online pair for: '{test}'")
|
|
54
|
-
|
|
55
|
-
return violations
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def validate_offline_coverage(file_path: str, project_root: Path) -> list[str]:
|
|
59
|
-
"""Check that online e2e test files have a corresponding offline file.
|
|
60
|
-
|
|
61
|
-
When a new online spec file is written, the offline equivalent must exist.
|
|
62
|
-
Returns blocking violations if offline file is missing.
|
|
63
|
-
"""
|
|
64
|
-
e2e_directory = project_root / E2E_TEST_DIRECTORY
|
|
65
|
-
file_name = Path(file_path).name
|
|
66
|
-
|
|
67
|
-
if "offline" in file_name:
|
|
68
|
-
return []
|
|
69
|
-
|
|
70
|
-
if not file_name.endswith(".spec.ts"):
|
|
71
|
-
return []
|
|
72
|
-
|
|
73
|
-
offline_name = file_name.replace(".spec.ts", ".offline.spec.ts")
|
|
74
|
-
if file_name == "online.spec.ts":
|
|
75
|
-
offline_name = "offline.spec.ts"
|
|
76
|
-
|
|
77
|
-
offline_path = e2e_directory / offline_name
|
|
78
|
-
if not offline_path.exists():
|
|
79
|
-
return [f"Missing offline equivalent: {offline_name} required for {file_name}"]
|
|
80
|
-
|
|
81
|
-
return []
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def main() -> None:
|
|
85
|
-
"""Hook entry point - reads tool input from stdin."""
|
|
86
|
-
try:
|
|
87
|
-
input_data = json.load(sys.stdin)
|
|
88
|
-
except json.JSONDecodeError:
|
|
89
|
-
sys.exit(0)
|
|
90
|
-
|
|
91
|
-
tool_input = input_data.get("tool_input", {})
|
|
92
|
-
file_path = tool_input.get("file_path", "")
|
|
93
|
-
|
|
94
|
-
if not file_path:
|
|
95
|
-
sys.exit(0)
|
|
96
|
-
|
|
97
|
-
if ".spec.ts" not in file_path:
|
|
98
|
-
sys.exit(0)
|
|
99
|
-
|
|
100
|
-
path_object = Path(file_path)
|
|
101
|
-
project_root = path_object.parent
|
|
102
|
-
while project_root != project_root.parent:
|
|
103
|
-
if (project_root / E2E_TEST_DIRECTORY).exists():
|
|
104
|
-
break
|
|
105
|
-
project_root = project_root.parent
|
|
106
|
-
else:
|
|
107
|
-
sys.exit(0)
|
|
108
|
-
|
|
109
|
-
if not (project_root / E2E_TEST_DIRECTORY).exists():
|
|
110
|
-
sys.exit(0)
|
|
111
|
-
|
|
112
|
-
naming_violations = validate_e2e_naming(project_root)
|
|
113
|
-
coverage_violations = validate_offline_coverage(file_path, project_root)
|
|
114
|
-
|
|
115
|
-
if coverage_violations:
|
|
116
|
-
violation_list = "; ".join(coverage_violations)
|
|
117
|
-
result = {
|
|
118
|
-
"hookSpecificOutput": {
|
|
119
|
-
"hookEventName": "PostToolUse",
|
|
120
|
-
"additionalContext": f"[E2E COVERAGE] Offline test required: {violation_list}"
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
print(json.dumps(result))
|
|
124
|
-
sys.stdout.flush()
|
|
125
|
-
sys.exit(0)
|
|
126
|
-
|
|
127
|
-
if naming_violations:
|
|
128
|
-
violation_list = "; ".join(naming_violations)
|
|
129
|
-
result = {
|
|
130
|
-
"hookSpecificOutput": {
|
|
131
|
-
"hookEventName": "PostToolUse",
|
|
132
|
-
"additionalContext": f"[E2E NAMING] {violation_list}. Offline tests must mirror online names with ' offline' suffix."
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
print(json.dumps(result))
|
|
136
|
-
sys.stdout.flush()
|
|
137
|
-
|
|
138
|
-
sys.exit(0)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if __name__ == "__main__":
|
|
142
|
-
main()
|