claude-dev-env 1.0.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/LICENSE +21 -0
- package/README.md +219 -0
- package/agents/agent-writer.md +157 -0
- package/agents/clasp-deployment-orchestrator.md +609 -0
- package/agents/clean-coder.md +295 -0
- package/agents/code-quality-agent.md +40 -0
- package/agents/code-standards-agent.md +93 -0
- package/agents/config-centralizer.md +686 -0
- package/agents/config-extraction-agent.md +225 -0
- package/agents/doc-orchestrator.md +47 -0
- package/agents/docs-agent.md +112 -0
- package/agents/docx-agent.md +211 -0
- package/agents/git-commit-crafter.md +100 -0
- package/agents/magic-value-eliminator-agent.md +72 -0
- package/agents/mandatory-agent-workflow-agent.md +88 -0
- package/agents/parallel-workflow-coordinator.md +779 -0
- package/agents/pdf-agent.md +302 -0
- package/agents/plan-executor.md +226 -0
- package/agents/pr-description-writer.md +87 -0
- package/agents/project-context-loader.md +238 -0
- package/agents/project-docs-analyzer.md +54 -0
- package/agents/project-structure-organizer-agent.md +72 -0
- package/agents/readability-review-agent.md +76 -0
- package/agents/refactoring-specialist.md +69 -0
- package/agents/right-sized-engineer.md +129 -0
- package/agents/session-continuity-manager.md +53 -0
- package/agents/skill-to-agent-converter.md +371 -0
- package/agents/skill-writer-agent.md +470 -0
- package/agents/stub-detector-agent.md +140 -0
- package/agents/tdd-test-writer.md +62 -0
- package/agents/test-data-builder.md +68 -0
- package/agents/tooling-builder.md +78 -0
- package/agents/user-docs-writer.md +67 -0
- package/agents/validation-expert.md +71 -0
- package/agents/workflow-visual-documenter.md +82 -0
- package/agents/xlsx-agent.md +169 -0
- package/bin/install.mjs +256 -0
- package/commands/commit.md +28 -0
- package/commands/docupdate.md +322 -0
- package/commands/implement.md +102 -0
- package/commands/initialize.md +91 -0
- package/commands/plan.md +63 -0
- package/commands/pr-comments.md +47 -0
- package/commands/readability-review.md +20 -0
- package/commands/review-plan.md +7 -0
- package/commands/right-size.md +15 -0
- package/commands/stubcheck.md +89 -0
- package/commands/sum.md +30 -0
- package/docs/CODE_RULES.md +186 -0
- package/docs/DJANGO_PATTERNS.md +80 -0
- package/docs/REACT_PATTERNS.md +185 -0
- package/docs/TEST_QUALITY.md +104 -0
- package/hooks/advisory/migration-safety-advisor.py +49 -0
- package/hooks/advisory/refactor-guard.py +205 -0
- package/hooks/blocking/block-main-commit.py +168 -0
- package/hooks/blocking/code-rules-enforcer.py +549 -0
- package/hooks/blocking/destructive-command-blocker.py +107 -0
- package/hooks/blocking/docker-settings-guard.py +44 -0
- package/hooks/blocking/hedging-language-blocker.py +130 -0
- package/hooks/blocking/parallel-task-blocker.py +69 -0
- package/hooks/blocking/pr-description-enforcer.py +87 -0
- package/hooks/blocking/pyautogui-scroll-blocker.py +74 -0
- package/hooks/blocking/sensitive-file-protector.py +70 -0
- package/hooks/blocking/tdd-enforcer.py +62 -0
- package/hooks/blocking/test-preflight-check.py +343 -0
- package/hooks/blocking/write-existing-file-blocker.py +63 -0
- package/hooks/git-hooks/post-commit.py +103 -0
- package/hooks/github-action/test_workflow.py +33 -0
- package/hooks/hooks.json +246 -0
- package/hooks/lifecycle/config-change-guard.py +84 -0
- package/hooks/lifecycle/session-end-cleanup.py +59 -0
- package/hooks/notification/attention-needed-notify.py +63 -0
- package/hooks/notification/claude-notification-handler.py +59 -0
- package/hooks/notification/notification_utils.py +206 -0
- package/hooks/rewrite-plugin-paths.py +116 -0
- package/hooks/session/bulk-edit-reminder.py +30 -0
- package/hooks/session/code-rules-reminder.py +97 -0
- package/hooks/session/compact-context-reinject.py +39 -0
- package/hooks/session/hook-structure-context.py +140 -0
- package/hooks/session/plugin-data-dir-cleanup.py +39 -0
- package/hooks/validation/code-style-validator.py +145 -0
- package/hooks/validation/e2e-test-validator.py +142 -0
- package/hooks/validation/hook-format-validator.py +66 -0
- package/hooks/validation/mypy_validator.py +180 -0
- package/hooks/validators/README.md +125 -0
- package/hooks/validators/VALIDATION_REPORT.md +287 -0
- package/hooks/validators/__init__.py +19 -0
- package/hooks/validators/abbreviation_checks.py +82 -0
- package/hooks/validators/code_quality_checks.py +133 -0
- package/hooks/validators/comment_checks.py +188 -0
- package/hooks/validators/file_structure_checks.py +182 -0
- package/hooks/validators/git_checks.py +107 -0
- package/hooks/validators/health_check.py +214 -0
- package/hooks/validators/magic_value_checks.py +81 -0
- package/hooks/validators/mypy_integration.py +52 -0
- package/hooks/validators/output_formatter.py +266 -0
- package/hooks/validators/pr_reference_checks.py +72 -0
- package/hooks/validators/python_antipattern_checks.py +110 -0
- package/hooks/validators/python_style_checks.py +364 -0
- package/hooks/validators/react_checks.py +90 -0
- package/hooks/validators/ruff_integration.py +80 -0
- package/hooks/validators/run_all_validators.py +772 -0
- package/hooks/validators/security_checks.py +135 -0
- package/hooks/validators/test_abbreviation_checks.py +76 -0
- package/hooks/validators/test_bad.tsx +7 -0
- package/hooks/validators/test_code_quality_checks.py +129 -0
- package/hooks/validators/test_file_structure_checks.py +307 -0
- package/hooks/validators/test_files/01_basic_component.tsx +10 -0
- package/hooks/validators/test_files/02_component_without_react.tsx +10 -0
- package/hooks/validators/test_files/03_pure_component.tsx +10 -0
- package/hooks/validators/test_files/04_pure_component_import.tsx +10 -0
- package/hooks/validators/test_files/05_typescript_generics.tsx +14 -0
- package/hooks/validators/test_files/06_typescript_two_generics.tsx +18 -0
- package/hooks/validators/test_files/07_multiline_declaration.tsx +11 -0
- package/hooks/validators/test_files/08_error_boundary_valid.tsx +14 -0
- package/hooks/validators/test_files/09_error_boundary_with_other_class.tsx +20 -0
- package/hooks/validators/test_files/10_inheritance_chain.tsx +16 -0
- package/hooks/validators/test_files/11_ts_file.ts +10 -0
- package/hooks/validators/test_files/12_non_react_class.tsx +14 -0
- package/hooks/validators/test_files/13_functional_component.tsx +8 -0
- package/hooks/validators/test_files/14_indented_class.tsx +13 -0
- package/hooks/validators/test_files/15_getDerivedStateFromError.tsx +14 -0
- package/hooks/validators/test_files/16_mixed_components.tsx +20 -0
- package/hooks/validators/test_files/EXECUTIVE_SUMMARY.md +175 -0
- package/hooks/validators/test_files/TEST_RESULTS_TABLE.txt +60 -0
- package/hooks/validators/test_files/VALIDATION_REPORT.md +201 -0
- package/hooks/validators/test_files/async_views.py +23 -0
- package/hooks/validators/test_files/async_with_imports.py +14 -0
- package/hooks/validators/test_files/bad_inline_imports.py +37 -0
- package/hooks/validators/test_files/management/commands/cmd_01_no_debug_check.py +10 -0
- package/hooks/validators/test_files/management/commands/cmd_02_proper_debug_check.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_03_debug_check_with_return.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_04_imported_DEBUG.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_05_debug_check_in_helper.py +16 -0
- package/hooks/validators/test_files/management/commands/cmd_06_debug_check_late.py +22 -0
- package/hooks/validators/test_files/management/commands/cmd_07_positive_debug_check.py +15 -0
- package/hooks/validators/test_files/management/commands/cmd_08_debug_with_and.py +14 -0
- package/hooks/validators/test_files/not_management_command.py +10 -0
- package/hooks/validators/test_files/skip_decorators/test_01_simple_skip.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_02_pytest_skipif.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_03_unittest_skipIf.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_04_skip_with_parens.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_05_xfail.py +7 -0
- package/hooks/validators/test_files/skip_decorators/test_06_custom_skip.py +11 -0
- package/hooks/validators/test_files/skip_decorators/test_07_capital_Skip.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_08_skipUnless.py +7 -0
- package/hooks/validators/test_files/skip_decorators/test_09_pytest_mark_skip_simple.py +7 -0
- package/hooks/validators/test_files/test_async_functions.py +45 -0
- package/hooks/validators/test_files/test_purecomponent/PureComponentExample.tsx +7 -0
- package/hooks/validators/test_files/test_purecomponent/ReactPureComponentExample.tsx +7 -0
- package/hooks/validators/test_git_checks.py +295 -0
- package/hooks/validators/test_good.tsx +5 -0
- package/hooks/validators/test_health_check.py +57 -0
- package/hooks/validators/test_magic_value_checks.py +63 -0
- package/hooks/validators/test_mypy_integration.py +27 -0
- package/hooks/validators/test_output_formatter.py +150 -0
- package/hooks/validators/test_pr_reference_checks.py +41 -0
- package/hooks/validators/test_python_antipattern_checks.py +113 -0
- package/hooks/validators/test_python_style_checks.py +439 -0
- package/hooks/validators/test_react_checks.py +213 -0
- package/hooks/validators/test_results.txt +25 -0
- package/hooks/validators/test_ruff_integration.py +27 -0
- package/hooks/validators/test_run_all_validators.py +228 -0
- package/hooks/validators/test_run_all_validators_integration.py +48 -0
- package/hooks/validators/test_safety_checks.py +243 -0
- package/hooks/validators/test_security_checks.py +105 -0
- package/hooks/validators/test_test_safety_checks.py +321 -0
- package/hooks/validators/test_todo_checks.py +39 -0
- package/hooks/validators/test_type_safety_checks.py +85 -0
- package/hooks/validators/test_useless_test_checks.py +55 -0
- package/hooks/validators/test_validator_base.py +26 -0
- package/hooks/validators/test_verify_paths.py +34 -0
- package/hooks/validators/todo_checks.py +59 -0
- package/hooks/validators/type_safety_checks.py +101 -0
- package/hooks/validators/useless_test_checks.py +92 -0
- package/hooks/validators/validator_base.py +19 -0
- package/hooks/validators/verify_paths.py +57 -0
- package/hooks/workflow/auto-formatter.py +114 -0
- package/hooks/workflow/investigation-tracker-reset.py +46 -0
- package/package.json +30 -0
- package/rules/agent-spawn-protocol.md +47 -0
- package/rules/cleanup-temp-files.md +27 -0
- package/rules/code-reviews.md +11 -0
- package/rules/code-standards.md +43 -0
- package/rules/conservative-action.md +20 -0
- package/rules/context7.md +12 -0
- package/rules/explore-thoroughly.md +27 -0
- package/rules/git-workflow.md +42 -0
- package/rules/parallel-tools.md +23 -0
- package/rules/research-mode.md +23 -0
- package/rules/right-sized-engineering.md +28 -0
- package/rules/tdd.md +7 -0
- package/rules/testing.md +12 -0
- package/skills/agent-prompt/SKILL.md +102 -0
- package/skills/anthropic-plan/SKILL.md +107 -0
- package/skills/everything-search/SKILL.md +144 -0
- package/skills/ingest/SKILL.md +40 -0
- package/skills/npm-creator/SKILL.md +183 -0
- package/skills/pr-review-responder/EXAMPLES.md +590 -0
- package/skills/pr-review-responder/PRINCIPLES.md +539 -0
- package/skills/pr-review-responder/README.md +209 -0
- package/skills/pr-review-responder/SKILL.md +202 -0
- package/skills/pr-review-responder/TESTING.md +407 -0
- package/skills/pr-review-responder/scripts/respond_to_reviews.py +376 -0
- package/skills/pr-review-responder/update_skill.py +297 -0
- package/skills/prompt-generator/REFERENCE.md +150 -0
- package/skills/prompt-generator/SKILL.md +154 -0
- package/skills/readability-review/SKILL.md +127 -0
- package/skills/recall/SKILL.md +27 -0
- package/skills/remember/SKILL.md +63 -0
- package/skills/rule-audit/SKILL.md +307 -0
- package/skills/rule-creator/SKILL.md +150 -0
- package/skills/skill-writer/REFERENCE.md +246 -0
- package/skills/skill-writer/SKILL.md +270 -0
- package/skills/tdd-team/SKILL.md +128 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PreToolUse:Bash hook that validates prerequisites before running test commands.
|
|
4
|
+
|
|
5
|
+
Intercepts playwright/pytest commands and checks:
|
|
6
|
+
1. Target server is reachable and healthy
|
|
7
|
+
2. Database file exists (for Django projects)
|
|
8
|
+
3. (Playwright only) Django server has --test-db flag
|
|
9
|
+
4. (Playwright only) Frontend builds successfully before e2e tests
|
|
10
|
+
|
|
11
|
+
Blocks doomed test runs early instead of letting them hang for minutes.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import psutil
|
|
22
|
+
except Exception:
|
|
23
|
+
psutil = None
|
|
24
|
+
|
|
25
|
+
TEST_COMMAND_PATTERNS = [
|
|
26
|
+
re.compile(r'\bplaywright\s+test\b'),
|
|
27
|
+
re.compile(r'\bnpx\s+playwright\b'),
|
|
28
|
+
re.compile(r'\bpytest\b'),
|
|
29
|
+
re.compile(r'\bpython\s+-m\s+pytest\b'),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
SERVER_URL_PATTERN = re.compile(r'https?://[^\s"\']+')
|
|
33
|
+
|
|
34
|
+
DEFAULT_PLAYWRIGHT_URL = "http://localhost:3000"
|
|
35
|
+
DEFAULT_DJANGO_URL = "http://localhost:8000"
|
|
36
|
+
|
|
37
|
+
CURL_TIMEOUT_SECONDS = 2
|
|
38
|
+
DJANGO_DB_FILENAME = "db.sqlite3"
|
|
39
|
+
|
|
40
|
+
BLOCKED_STATUS_CODES = {500, 502, 503, 504}
|
|
41
|
+
HEALTH_CHECK_ERROR_TEMPLATE = "BLOCKED: Server at {} is not healthy ({}). Fix the server before running tests."
|
|
42
|
+
UNREACHABLE_ERROR_TEMPLATE = "BLOCKED: Server at {} is unreachable. Start the server before running tests."
|
|
43
|
+
MISSING_DB_ERROR_TEMPLATE = "BLOCKED: No database file ({}) found in {}. Run migrations before running tests."
|
|
44
|
+
FRONTEND_BUILD_FAILED_MESSAGE = "BLOCKED: Frontend build failed. Fix build errors before running e2e tests."
|
|
45
|
+
MISSING_TEST_DB_FLAG_TEMPLATE = "BLOCKED: Django server on port {} is not running with --test-db. Restart with: python manage.py runserver --test-db 0.0.0.0:{}"
|
|
46
|
+
PORT_CONFLICT_ERROR_TEMPLATE = "BLOCKED: Multiple Django runserver processes are bound to port {} across worktrees: {}. Stop stale servers first."
|
|
47
|
+
FRONTEND_DIRECTORY_NAME = "frontend"
|
|
48
|
+
NPM_BUILD_COMMAND = "npm run build"
|
|
49
|
+
COLLECTSTATIC_COMMAND = "python manage.py collectstatic --noinput"
|
|
50
|
+
BUILD_TIMEOUT_SECONDS = 120
|
|
51
|
+
PLAYWRIGHT_COMMAND_PATTERNS = [
|
|
52
|
+
re.compile(r'\bplaywright\s+test\b'),
|
|
53
|
+
re.compile(r'\bnpx\s+playwright\b'),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_test_command(command: str) -> bool:
|
|
58
|
+
for each_pattern in TEST_COMMAND_PATTERNS:
|
|
59
|
+
if each_pattern.search(command):
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_playwright_command(command: str) -> bool:
|
|
65
|
+
for each_pattern in PLAYWRIGHT_COMMAND_PATTERNS:
|
|
66
|
+
if each_pattern.search(command):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_target_url(command: str) -> str:
|
|
72
|
+
url_match = SERVER_URL_PATTERN.search(command)
|
|
73
|
+
if url_match:
|
|
74
|
+
return url_match.group(0)
|
|
75
|
+
|
|
76
|
+
is_playwright = "playwright" in command
|
|
77
|
+
if is_playwright:
|
|
78
|
+
return DEFAULT_PLAYWRIGHT_URL
|
|
79
|
+
|
|
80
|
+
is_pytest = "pytest" in command
|
|
81
|
+
if is_pytest:
|
|
82
|
+
return DEFAULT_DJANGO_URL
|
|
83
|
+
|
|
84
|
+
return DEFAULT_PLAYWRIGHT_URL
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_server_health(target_url: str) -> str | None:
|
|
88
|
+
try:
|
|
89
|
+
curl_result = subprocess.run(
|
|
90
|
+
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", str(CURL_TIMEOUT_SECONDS), target_url],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
timeout=CURL_TIMEOUT_SECONDS + 1,
|
|
94
|
+
)
|
|
95
|
+
http_status_code = int(curl_result.stdout.strip())
|
|
96
|
+
except (subprocess.TimeoutExpired, ValueError, OSError):
|
|
97
|
+
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
98
|
+
|
|
99
|
+
if http_status_code == 0:
|
|
100
|
+
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
101
|
+
|
|
102
|
+
if http_status_code in BLOCKED_STATUS_CODES:
|
|
103
|
+
return HEALTH_CHECK_ERROR_TEMPLATE.format(target_url, f"HTTP {http_status_code}")
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_project_root(command: str) -> str | None:
|
|
109
|
+
working_directory = os.environ.get("PWD", os.getcwd())
|
|
110
|
+
|
|
111
|
+
directory_match = re.search(r'--project[= ](\S+)', command)
|
|
112
|
+
if directory_match:
|
|
113
|
+
return os.path.abspath(directory_match.group(1))
|
|
114
|
+
|
|
115
|
+
cd_match = re.search(r'cd\s+"([^"]+)"', command) or re.search(r"cd\s+'([^']+)'", command) or re.search(r'cd\s+(\S+)', command)
|
|
116
|
+
if cd_match:
|
|
117
|
+
cd_target = cd_match.group(1)
|
|
118
|
+
if os.path.isabs(cd_target):
|
|
119
|
+
return cd_target
|
|
120
|
+
return os.path.join(working_directory, cd_target)
|
|
121
|
+
|
|
122
|
+
return working_directory
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_django_database(command: str) -> str | None:
|
|
126
|
+
is_django_test = "pytest" in command or "manage.py" in command
|
|
127
|
+
if not is_django_test:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
project_root = find_project_root(command)
|
|
131
|
+
if not project_root:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
manage_py_path = os.path.join(project_root, "manage.py")
|
|
135
|
+
if not os.path.exists(manage_py_path):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
database_path = os.path.join(project_root, DJANGO_DB_FILENAME)
|
|
139
|
+
if os.path.exists(database_path):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
return MISSING_DB_ERROR_TEMPLATE.format(DJANGO_DB_FILENAME, project_root)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def find_frontend_directory(command: str) -> str | None:
|
|
146
|
+
project_root = find_project_root(command)
|
|
147
|
+
if not project_root:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
frontend_path = os.path.join(project_root, FRONTEND_DIRECTORY_NAME)
|
|
151
|
+
if os.path.isdir(frontend_path):
|
|
152
|
+
return frontend_path
|
|
153
|
+
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_frontend(command: str) -> str | None:
|
|
158
|
+
frontend_path = find_frontend_directory(command)
|
|
159
|
+
if not frontend_path:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
project_root = find_project_root(command)
|
|
163
|
+
|
|
164
|
+
npm_build_result = subprocess.run(
|
|
165
|
+
NPM_BUILD_COMMAND.split(),
|
|
166
|
+
cwd=frontend_path,
|
|
167
|
+
capture_output=True,
|
|
168
|
+
text=True,
|
|
169
|
+
timeout=BUILD_TIMEOUT_SECONDS,
|
|
170
|
+
)
|
|
171
|
+
if npm_build_result.returncode != 0:
|
|
172
|
+
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
173
|
+
|
|
174
|
+
collectstatic_result = subprocess.run(
|
|
175
|
+
COLLECTSTATIC_COMMAND.split(),
|
|
176
|
+
cwd=project_root,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
timeout=BUILD_TIMEOUT_SECONDS,
|
|
180
|
+
)
|
|
181
|
+
if collectstatic_result.returncode != 0:
|
|
182
|
+
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def extract_port_from_url(target_url: str) -> str:
|
|
188
|
+
parsed_url = urlparse(target_url)
|
|
189
|
+
if parsed_url.port:
|
|
190
|
+
return str(parsed_url.port)
|
|
191
|
+
return "8000"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_test_db_flag(target_url: str) -> str | None:
|
|
195
|
+
port = extract_port_from_url(target_url)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
ps_result = subprocess.run(
|
|
199
|
+
["ps", "aux"],
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
timeout=CURL_TIMEOUT_SECONDS,
|
|
203
|
+
)
|
|
204
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
is_runserver_found = False
|
|
208
|
+
for each_line in ps_result.stdout.splitlines():
|
|
209
|
+
if "runserver" not in each_line:
|
|
210
|
+
continue
|
|
211
|
+
if "grep" in each_line:
|
|
212
|
+
continue
|
|
213
|
+
is_runserver_found = True
|
|
214
|
+
if "--test-db" in each_line:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
if not is_runserver_found:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
return MISSING_TEST_DB_FLAG_TEMPLATE.format(port, port)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_runserver_processes_on_port(target_port: str) -> list[tuple[int, str]]:
|
|
224
|
+
if psutil is None:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
runserver_processes: list[tuple[int, str]] = []
|
|
228
|
+
port_token = f":{target_port}"
|
|
229
|
+
|
|
230
|
+
for each_process in psutil.process_iter(["pid", "cmdline", "cwd"]):
|
|
231
|
+
try:
|
|
232
|
+
commandline_parts = each_process.info.get("cmdline") or []
|
|
233
|
+
if len(commandline_parts) < 3:
|
|
234
|
+
continue
|
|
235
|
+
if commandline_parts[1] != "manage.py" or commandline_parts[2] != "runserver":
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
full_commandline = " ".join(commandline_parts)
|
|
239
|
+
if port_token not in full_commandline:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
process_working_directory = each_process.info.get("cwd") or ""
|
|
243
|
+
runserver_processes.append((each_process.info["pid"], process_working_directory))
|
|
244
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, KeyError):
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
return runserver_processes
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def check_runserver_port_conflicts(target_url: str, project_root: str | None) -> str | None:
|
|
251
|
+
parsed_target_url = urlparse(target_url)
|
|
252
|
+
target_host = parsed_target_url.hostname or ""
|
|
253
|
+
if target_host not in {"localhost", "127.0.0.1", "0.0.0.0"}:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
target_port = str(parsed_target_url.port or 8000)
|
|
257
|
+
runserver_processes = _get_runserver_processes_on_port(target_port)
|
|
258
|
+
if len(runserver_processes) <= 1:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
project_root_realpath = os.path.realpath(project_root) if project_root else None
|
|
262
|
+
unique_directories: set[str] = set()
|
|
263
|
+
for _, directory_path in runserver_processes:
|
|
264
|
+
if not directory_path:
|
|
265
|
+
continue
|
|
266
|
+
unique_directories.add(os.path.realpath(directory_path))
|
|
267
|
+
|
|
268
|
+
if len(unique_directories) <= 1:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
if project_root_realpath and project_root_realpath in unique_directories:
|
|
272
|
+
other_worktrees = sorted(path for path in unique_directories if path != project_root_realpath)
|
|
273
|
+
if not other_worktrees:
|
|
274
|
+
return None
|
|
275
|
+
return PORT_CONFLICT_ERROR_TEMPLATE.format(target_port, ", ".join(other_worktrees))
|
|
276
|
+
|
|
277
|
+
return PORT_CONFLICT_ERROR_TEMPLATE.format(target_port, ", ".join(sorted(unique_directories)))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_deny_response(reason: str) -> dict:
|
|
281
|
+
return {
|
|
282
|
+
"hookSpecificOutput": {
|
|
283
|
+
"hookEventName": "PreToolUse",
|
|
284
|
+
"permissionDecision": "deny",
|
|
285
|
+
"permissionDecisionReason": reason,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main() -> None:
|
|
291
|
+
try:
|
|
292
|
+
hook_input = json.load(sys.stdin)
|
|
293
|
+
except json.JSONDecodeError:
|
|
294
|
+
sys.exit(0)
|
|
295
|
+
|
|
296
|
+
tool_name = hook_input.get("tool_name", "")
|
|
297
|
+
if tool_name != "Bash":
|
|
298
|
+
sys.exit(0)
|
|
299
|
+
|
|
300
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
301
|
+
if not is_test_command(command):
|
|
302
|
+
sys.exit(0)
|
|
303
|
+
|
|
304
|
+
project_root = find_project_root(command)
|
|
305
|
+
is_django_project = project_root and os.path.exists(os.path.join(project_root, "manage.py"))
|
|
306
|
+
|
|
307
|
+
if not is_django_project and not is_playwright_command(command):
|
|
308
|
+
sys.exit(0)
|
|
309
|
+
|
|
310
|
+
database_error = check_django_database(command)
|
|
311
|
+
if database_error:
|
|
312
|
+
print(json.dumps(build_deny_response(database_error)))
|
|
313
|
+
sys.exit(0)
|
|
314
|
+
|
|
315
|
+
target_url = extract_target_url(command)
|
|
316
|
+
|
|
317
|
+
is_e2e_test = is_playwright_command(command)
|
|
318
|
+
if is_e2e_test:
|
|
319
|
+
conflict_error = check_runserver_port_conflicts(target_url, project_root)
|
|
320
|
+
if conflict_error:
|
|
321
|
+
print(json.dumps(build_deny_response(conflict_error)))
|
|
322
|
+
sys.exit(0)
|
|
323
|
+
|
|
324
|
+
test_db_error = check_test_db_flag(target_url)
|
|
325
|
+
if test_db_error:
|
|
326
|
+
print(json.dumps(build_deny_response(test_db_error)))
|
|
327
|
+
sys.exit(0)
|
|
328
|
+
|
|
329
|
+
frontend_build_error = build_frontend(command)
|
|
330
|
+
if frontend_build_error:
|
|
331
|
+
print(json.dumps(build_deny_response(frontend_build_error)))
|
|
332
|
+
sys.exit(0)
|
|
333
|
+
|
|
334
|
+
server_error = check_server_health(target_url)
|
|
335
|
+
if server_error:
|
|
336
|
+
print(json.dumps(build_deny_response(server_error)))
|
|
337
|
+
sys.exit(0)
|
|
338
|
+
|
|
339
|
+
sys.exit(0)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if __name__ == "__main__":
|
|
343
|
+
main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse:Write hook — blocks Write tool when the target file already exists.
|
|
3
|
+
|
|
4
|
+
Agents should use Edit for modifying existing files. Write is only for new file creation.
|
|
5
|
+
Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone scripts).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
JUPYTER_EXTENSION = ".ipynb"
|
|
13
|
+
HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_jupyter_notebook(file_path: str) -> bool:
|
|
17
|
+
return file_path.lower().endswith(JUPYTER_EXTENSION)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_inside_hooks_directory(file_path: str) -> bool:
|
|
21
|
+
normalized_path = os.path.normpath(file_path)
|
|
22
|
+
return normalized_path.startswith(HOOKS_DIRECTORY)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
try:
|
|
27
|
+
input_payload = json.load(sys.stdin)
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
sys.exit(0)
|
|
30
|
+
|
|
31
|
+
tool_name = input_payload.get("tool_name", "")
|
|
32
|
+
tool_input = input_payload.get("tool_input", {})
|
|
33
|
+
|
|
34
|
+
if tool_name != "Write":
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
target_file_path = tool_input.get("file_path", "")
|
|
38
|
+
|
|
39
|
+
if not target_file_path:
|
|
40
|
+
sys.exit(0)
|
|
41
|
+
|
|
42
|
+
if is_jupyter_notebook(target_file_path):
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
if is_inside_hooks_directory(target_file_path):
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
|
|
48
|
+
if not os.path.exists(target_file_path):
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
|
|
51
|
+
denial = {
|
|
52
|
+
"hookSpecificOutput": {
|
|
53
|
+
"hookEventName": "PreToolUse",
|
|
54
|
+
"permissionDecision": "deny",
|
|
55
|
+
"permissionDecisionReason": f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead.",
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
print(json.dumps(denial))
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Git post-commit hook: Auto-update parent repos when committing in a submodule.
|
|
4
|
+
|
|
5
|
+
When you commit in a submodule, this hook:
|
|
6
|
+
1. Detects if current repo is a submodule of a parent
|
|
7
|
+
2. Stages the submodule update in the parent
|
|
8
|
+
3. Creates a commit in the parent pointing to the new submodule commit
|
|
9
|
+
|
|
10
|
+
This prevents the "lost work" issue where submodule commits aren't tracked by parent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_git(*args: str, cwd: Path | None = None) -> str:
|
|
20
|
+
"""Run a git command and return output."""
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["git"] + list(args),
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
)
|
|
27
|
+
return result.stdout.strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_parent_repo(repo_dir: Path) -> Path | None:
|
|
31
|
+
"""Find parent repo that has this as a submodule."""
|
|
32
|
+
repo_name = repo_dir.name
|
|
33
|
+
parent_dir = repo_dir.parent
|
|
34
|
+
|
|
35
|
+
while parent_dir != parent_dir.parent:
|
|
36
|
+
git_path = parent_dir / ".git"
|
|
37
|
+
gitmodules_path = parent_dir / ".gitmodules"
|
|
38
|
+
|
|
39
|
+
if git_path.exists() and gitmodules_path.exists():
|
|
40
|
+
try:
|
|
41
|
+
content = gitmodules_path.read_text()
|
|
42
|
+
if f"path = {repo_name}" in content:
|
|
43
|
+
return parent_dir
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
parent_dir = parent_dir.parent
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> int:
|
|
53
|
+
"""Main hook logic."""
|
|
54
|
+
try:
|
|
55
|
+
repo_dir = Path(run_git("rev-parse", "--show-toplevel"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
repo_name = repo_dir.name
|
|
60
|
+
parent_repo = find_parent_repo(repo_dir)
|
|
61
|
+
|
|
62
|
+
if not parent_repo:
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
|
|
66
|
+
commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
|
|
67
|
+
|
|
68
|
+
print()
|
|
69
|
+
print("=== Submodule Parent Update ===")
|
|
70
|
+
print(f"Submodule: {repo_name} @ {commit_hash}")
|
|
71
|
+
print(f"Parent: {parent_repo}")
|
|
72
|
+
|
|
73
|
+
run_git("add", repo_name, cwd=parent_repo)
|
|
74
|
+
|
|
75
|
+
diff_result = subprocess.run(
|
|
76
|
+
["git", "diff", "--cached", "--quiet"],
|
|
77
|
+
cwd=parent_repo,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if diff_result.returncode == 0:
|
|
81
|
+
print("Parent already up to date.")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
full_commit_msg = f"""chore: update {repo_name} submodule to {commit_hash}
|
|
85
|
+
|
|
86
|
+
Submodule commit: {commit_msg}
|
|
87
|
+
|
|
88
|
+
Co-Authored-By: Claude <noreply@anthropic.com>"""
|
|
89
|
+
|
|
90
|
+
subprocess.run(
|
|
91
|
+
["git", "commit", "-m", full_commit_msg],
|
|
92
|
+
cwd=parent_repo,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
print("Parent updated successfully.")
|
|
96
|
+
print("================================")
|
|
97
|
+
print()
|
|
98
|
+
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tests for GitHub Action workflow YAML validity."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_workflow_is_valid_yaml() -> None:
|
|
9
|
+
"""Test that the workflow file is valid YAML."""
|
|
10
|
+
workflow_path = Path(__file__).parent / "pre-push-review.yml"
|
|
11
|
+
assert workflow_path.exists(), "Workflow file must exist"
|
|
12
|
+
|
|
13
|
+
with open(workflow_path) as f:
|
|
14
|
+
data = yaml.safe_load(f)
|
|
15
|
+
|
|
16
|
+
assert "name" in data
|
|
17
|
+
assert "on" in data or True in data
|
|
18
|
+
assert "jobs" in data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_workflow_has_validate_job() -> None:
|
|
22
|
+
"""Test that workflow has a validate job with required steps."""
|
|
23
|
+
workflow_path = Path(__file__).parent / "pre-push-review.yml"
|
|
24
|
+
|
|
25
|
+
with open(workflow_path) as f:
|
|
26
|
+
data = yaml.safe_load(f)
|
|
27
|
+
|
|
28
|
+
assert "validate" in data["jobs"]
|
|
29
|
+
job = data["jobs"]["validate"]
|
|
30
|
+
assert "steps" in job
|
|
31
|
+
step_names = [s.get("name", "") for s in job["steps"]]
|
|
32
|
+
assert "Checkout code" in step_names
|
|
33
|
+
assert "Set up Python" in step_names
|