@hustle-together/api-dev-tools 3.12.3 → 3.12.16
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/.claude/commands/hustle-build.md +259 -0
- package/.claude/commands/hustle-combine.md +1089 -0
- package/.claude/commands/hustle-ui-create-page.md +1078 -0
- package/.claude/commands/hustle-ui-create.md +1058 -0
- package/.claude/hooks/auto-answer.py +305 -0
- package/.claude/hooks/cache-research.py +337 -0
- package/.claude/hooks/check-api-routes.py +168 -0
- package/.claude/hooks/check-playwright-setup.py +103 -0
- package/.claude/hooks/check-storybook-setup.py +81 -0
- package/.claude/hooks/check-update.py +132 -0
- package/.claude/hooks/completion-promise-detector.py +293 -0
- package/.claude/hooks/context-capacity-warning.py +171 -0
- package/.claude/hooks/detect-interruption.py +165 -0
- package/.claude/hooks/docs-update-check.py +120 -0
- package/.claude/hooks/enforce-a11y-audit.py +202 -0
- package/.claude/hooks/enforce-brand-guide.py +241 -0
- package/.claude/hooks/enforce-component-type-confirm.py +97 -0
- package/.claude/hooks/enforce-dry-run.py +134 -0
- package/.claude/hooks/enforce-freshness.py +184 -0
- package/.claude/hooks/enforce-page-components.py +186 -0
- package/.claude/hooks/enforce-page-data-schema.py +155 -0
- package/.claude/hooks/enforce-questions-sourced.py +146 -0
- package/.claude/hooks/enforce-schema-from-interview.py +248 -0
- package/.claude/hooks/enforce-ui-disambiguation.py +108 -0
- package/.claude/hooks/enforce-ui-interview.py +130 -0
- package/.claude/hooks/generate-adr-options.py +282 -0
- package/.claude/hooks/generate-manifest-entry.py +1161 -0
- package/.claude/hooks/hook_utils.py +609 -0
- package/.claude/hooks/lib/__init__.py +1 -0
- package/.claude/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/greptile.py +355 -0
- package/.claude/hooks/lib/ntfy.py +209 -0
- package/.claude/hooks/notify-input-needed.py +73 -0
- package/.claude/hooks/notify-phase-complete.py +90 -0
- package/.claude/hooks/ntfy-on-question.py +240 -0
- package/.claude/hooks/orchestrator-completion.py +313 -0
- package/.claude/hooks/orchestrator-handoff.py +267 -0
- package/.claude/hooks/orchestrator-session-startup.py +146 -0
- package/.claude/hooks/parallel-orchestrator.py +451 -0
- package/.claude/hooks/project-document-prompt.py +302 -0
- package/.claude/hooks/remote-question-proxy.py +284 -0
- package/.claude/hooks/remote-question-server.py +1224 -0
- package/.claude/hooks/run-code-review.py +393 -0
- package/.claude/hooks/run-visual-qa.py +338 -0
- package/.claude/hooks/session-logger.py +323 -0
- package/.claude/hooks/test-orchestrator-reground.py +248 -0
- package/.claude/hooks/track-scope-coverage.py +220 -0
- package/.claude/hooks/track-token-usage.py +121 -0
- package/.claude/hooks/update-adr-decision.py +236 -0
- package/.claude/hooks/update-api-showcase.py +161 -0
- package/.claude/hooks/update-registry.py +352 -0
- package/.claude/hooks/update-testing-checklist.py +195 -0
- package/.claude/hooks/update-ui-showcase.py +224 -0
- package/.claude/settings.local.json +7 -1
- package/.claude/test-auto-answer-bot.py +183 -0
- package/.claude/test-completion-detector.py +263 -0
- package/.claude/test-orchestrator-state.json +20 -0
- package/.claude/test-orchestrator.sh +271 -0
- package/.skills/api-create/SKILL.md +88 -3
- package/.skills/docs-sync/SKILL.md +260 -0
- package/.skills/hustle-build/SKILL.md +459 -0
- package/.skills/hustle-build-review/SKILL.md +518 -0
- package/CHANGELOG.md +87 -0
- package/README.md +86 -9
- package/bin/cli.js +1302 -88
- package/commands/hustle-api-create.md +22 -0
- package/commands/hustle-combine.md +81 -2
- package/commands/hustle-ui-create-page.md +84 -2
- package/commands/hustle-ui-create.md +82 -2
- package/hooks/auto-answer.py +228 -0
- package/hooks/check-update.py +132 -0
- package/hooks/ntfy-on-question.py +227 -0
- package/hooks/orchestrator-completion.py +313 -0
- package/hooks/orchestrator-handoff.py +189 -0
- package/hooks/orchestrator-session-startup.py +146 -0
- package/hooks/periodic-reground.py +230 -67
- package/hooks/update-api-showcase.py +13 -1
- package/hooks/update-ui-showcase.py +13 -1
- package/package.json +7 -3
- package/scripts/extract-schema-docs.cjs +322 -0
- package/templates/CLAUDE-SECTION.md +89 -64
- package/templates/api-showcase/_components/APIModal.tsx +100 -8
- package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
- package/templates/api-showcase/_components/APITester.tsx +367 -58
- package/templates/docs/page.tsx +230 -0
- package/templates/hustle-build-defaults.json +84 -0
- package/templates/hustle-dev-dashboard/page.tsx +365 -0
- package/templates/playwright-report/page.tsx +258 -0
- package/templates/settings.json +88 -7
- package/templates/test-results/page.tsx +237 -0
- package/templates/typedoc.json +19 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +1 -1
- package/templates/ui-showcase/page.tsx +1 -1
- package/.claude/api-dev-state.json +0 -466
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PostToolUse for Write/Edit
|
|
4
|
+
Purpose: Update .claude/registry.json when workflow completes Phase 13
|
|
5
|
+
|
|
6
|
+
This hook runs AFTER Claude writes/edits files. When it detects that
|
|
7
|
+
the completion phase status was just set to "complete" in api-dev-state.json,
|
|
8
|
+
it automatically updates the registry.json with the new entry.
|
|
9
|
+
|
|
10
|
+
Supports:
|
|
11
|
+
- API workflows (api-create) -> registry.apis
|
|
12
|
+
- Component workflows (ui-create-component) -> registry.components
|
|
13
|
+
- Page workflows (ui-create-page) -> registry.pages
|
|
14
|
+
- Combined workflows (combine-api, combine-ui) -> registry.combined
|
|
15
|
+
|
|
16
|
+
Version: 3.9.0
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
- {"continue": true} - Always continues (logging only, no blocking)
|
|
20
|
+
- For UI workflows, includes notify message with UI Showcase link
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
28
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
29
|
+
REGISTRY_FILE = Path(__file__).parent.parent / "registry.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_active_endpoint(state):
|
|
33
|
+
"""Get active endpoint - supports both old and new state formats."""
|
|
34
|
+
# New format (v3.6.7+): endpoints object with active_endpoint pointer
|
|
35
|
+
if "endpoints" in state and "active_endpoint" in state:
|
|
36
|
+
active = state.get("active_endpoint")
|
|
37
|
+
if active and active in state["endpoints"]:
|
|
38
|
+
return active, state["endpoints"][active]
|
|
39
|
+
return None, None
|
|
40
|
+
|
|
41
|
+
# Old format: single endpoint field
|
|
42
|
+
endpoint = state.get("endpoint")
|
|
43
|
+
if endpoint:
|
|
44
|
+
return endpoint, state
|
|
45
|
+
|
|
46
|
+
return None, None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_registry():
|
|
50
|
+
"""Load existing registry or create default."""
|
|
51
|
+
if REGISTRY_FILE.exists():
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(REGISTRY_FILE.read_text())
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"version": "1.0.0",
|
|
59
|
+
"updated_at": "",
|
|
60
|
+
"description": "Central registry tracking all APIs, components, and pages created through Hustle Dev Tools",
|
|
61
|
+
"apis": {},
|
|
62
|
+
"components": {},
|
|
63
|
+
"pages": {},
|
|
64
|
+
"combined": {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_registry(registry):
|
|
69
|
+
"""Save registry to file."""
|
|
70
|
+
registry["updated_at"] = datetime.now().isoformat()
|
|
71
|
+
REGISTRY_FILE.write_text(json.dumps(registry, indent=2))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_api_entry(endpoint_name, endpoint_state, state):
|
|
75
|
+
"""Extract registry entry from state for a standard API."""
|
|
76
|
+
phases = endpoint_state.get("phases", state.get("phases", {}))
|
|
77
|
+
interview = phases.get("interview", {})
|
|
78
|
+
decisions = interview.get("decisions", {})
|
|
79
|
+
|
|
80
|
+
# Get purpose from scope or interview
|
|
81
|
+
scope = phases.get("scope", {})
|
|
82
|
+
purpose = scope.get("purpose", decisions.get("purpose", {}).get("response", ""))
|
|
83
|
+
|
|
84
|
+
# Get schema file path
|
|
85
|
+
schema = phases.get("schema_creation", {})
|
|
86
|
+
schema_file = schema.get("schema_file", f"src/app/api/v2/{endpoint_name}/schemas.ts")
|
|
87
|
+
|
|
88
|
+
# Get test file path
|
|
89
|
+
tdd_red = phases.get("tdd_red", {})
|
|
90
|
+
test_file = tdd_red.get("test_file", f"src/app/api/v2/{endpoint_name}/__tests__/{endpoint_name}.api.test.ts")
|
|
91
|
+
|
|
92
|
+
# Get implementation file path
|
|
93
|
+
tdd_green = phases.get("tdd_green", {})
|
|
94
|
+
impl_file = tdd_green.get("implementation_file", f"src/app/api/v2/{endpoint_name}/route.ts")
|
|
95
|
+
|
|
96
|
+
# Determine methods from interview decisions or default
|
|
97
|
+
methods = ["POST"]
|
|
98
|
+
if decisions.get("methods"):
|
|
99
|
+
methods = decisions.get("methods", {}).get("value", ["POST"])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"name": endpoint_name.replace("-", " ").title(),
|
|
103
|
+
"description": purpose[:200] if purpose else f"API endpoint for {endpoint_name}",
|
|
104
|
+
"route": impl_file,
|
|
105
|
+
"schemas": schema_file,
|
|
106
|
+
"tests": test_file,
|
|
107
|
+
"methods": methods if isinstance(methods, list) else [methods],
|
|
108
|
+
"created_at": datetime.now().strftime("%Y-%m-%d"),
|
|
109
|
+
"status": "complete"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_combined_entry(endpoint_name, endpoint_state, state):
|
|
114
|
+
"""Extract registry entry for a combined API."""
|
|
115
|
+
combine_config = state.get("combine_config", endpoint_state.get("combine_config", {}))
|
|
116
|
+
phases = endpoint_state.get("phases", state.get("phases", {}))
|
|
117
|
+
interview = phases.get("interview", {})
|
|
118
|
+
decisions = interview.get("decisions", {})
|
|
119
|
+
|
|
120
|
+
# Get source APIs
|
|
121
|
+
source_elements = combine_config.get("source_elements", [])
|
|
122
|
+
combines = [elem.get("name") for elem in source_elements if elem.get("type") == "api"]
|
|
123
|
+
|
|
124
|
+
# Get purpose from scope
|
|
125
|
+
scope = phases.get("scope", {})
|
|
126
|
+
purpose = scope.get("purpose", "")
|
|
127
|
+
|
|
128
|
+
# Get flow type from interview
|
|
129
|
+
flow_type = decisions.get("execution_order", decisions.get("flow_type", "sequential"))
|
|
130
|
+
if isinstance(flow_type, dict):
|
|
131
|
+
flow_type = flow_type.get("value", "sequential")
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"name": endpoint_name.replace("-", " ").title(),
|
|
135
|
+
"type": "api",
|
|
136
|
+
"description": purpose[:200] if purpose else f"Combined API: {', '.join(combines)}",
|
|
137
|
+
"combines": combines,
|
|
138
|
+
"route": f"src/app/api/v2/{endpoint_name}/route.ts",
|
|
139
|
+
"schemas": f"src/app/api/v2/{endpoint_name}/schemas.ts",
|
|
140
|
+
"tests": f"src/app/api/v2/{endpoint_name}/__tests__/",
|
|
141
|
+
"flow_type": flow_type,
|
|
142
|
+
"created_at": datetime.now().strftime("%Y-%m-%d"),
|
|
143
|
+
"status": "complete"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def extract_component_entry(element_name, element_state, state):
|
|
148
|
+
"""Extract registry entry for a UI component."""
|
|
149
|
+
phases = element_state.get("phases", state.get("phases", {}))
|
|
150
|
+
ui_config = state.get("ui_config", element_state.get("ui_config", {}))
|
|
151
|
+
interview = phases.get("interview", {})
|
|
152
|
+
decisions = interview.get("decisions", {})
|
|
153
|
+
|
|
154
|
+
# Get description from scope
|
|
155
|
+
scope = phases.get("scope", {})
|
|
156
|
+
description = scope.get("component_purpose", scope.get("purpose", ""))
|
|
157
|
+
|
|
158
|
+
# Get component type (atom, molecule, organism)
|
|
159
|
+
component_type = ui_config.get("component_type", decisions.get("component_type", {}).get("value", "atom"))
|
|
160
|
+
|
|
161
|
+
# Get variants from interview decisions
|
|
162
|
+
variants = ui_config.get("variants", [])
|
|
163
|
+
if not variants and decisions.get("variants"):
|
|
164
|
+
variants = decisions.get("variants", {}).get("value", [])
|
|
165
|
+
|
|
166
|
+
# Get accessibility level
|
|
167
|
+
accessibility = ui_config.get("accessibility_level", "wcag2aa")
|
|
168
|
+
|
|
169
|
+
# File paths (PascalCase for component name)
|
|
170
|
+
pascal_name = "".join(word.capitalize() for word in element_name.replace("-", " ").split())
|
|
171
|
+
base_path = f"src/components/{pascal_name}"
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"name": pascal_name,
|
|
175
|
+
"description": description[:200] if description else f"UI component: {pascal_name}",
|
|
176
|
+
"type": component_type,
|
|
177
|
+
"file": f"{base_path}/{pascal_name}.tsx",
|
|
178
|
+
"story": f"{base_path}/{pascal_name}.stories.tsx",
|
|
179
|
+
"tests": f"{base_path}/{pascal_name}.test.tsx",
|
|
180
|
+
"props_interface": f"{pascal_name}Props",
|
|
181
|
+
"variants": variants if isinstance(variants, list) else [],
|
|
182
|
+
"accessibility": accessibility,
|
|
183
|
+
"responsive": True,
|
|
184
|
+
"status": "complete",
|
|
185
|
+
"created_at": datetime.now().strftime("%Y-%m-%d")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def extract_page_entry(element_name, element_state, state):
|
|
190
|
+
"""Extract registry entry for a page."""
|
|
191
|
+
phases = element_state.get("phases", state.get("phases", {}))
|
|
192
|
+
ui_config = state.get("ui_config", element_state.get("ui_config", {}))
|
|
193
|
+
interview = phases.get("interview", {})
|
|
194
|
+
decisions = interview.get("decisions", {})
|
|
195
|
+
|
|
196
|
+
# Get description from scope
|
|
197
|
+
scope = phases.get("scope", {})
|
|
198
|
+
description = scope.get("page_purpose", scope.get("purpose", ""))
|
|
199
|
+
|
|
200
|
+
# Get page type (landing, dashboard, form, list)
|
|
201
|
+
page_type = ui_config.get("page_type", decisions.get("page_type", {}).get("value", "landing"))
|
|
202
|
+
|
|
203
|
+
# Get components used (from component analysis phase)
|
|
204
|
+
component_analysis = phases.get("component_analysis", {})
|
|
205
|
+
uses_components = component_analysis.get("selected_components", [])
|
|
206
|
+
if not uses_components:
|
|
207
|
+
uses_components = ui_config.get("uses_components", [])
|
|
208
|
+
|
|
209
|
+
# Get data fetching type from interview
|
|
210
|
+
data_fetching = decisions.get("data_fetching", {}).get("value", "server")
|
|
211
|
+
if isinstance(data_fetching, dict):
|
|
212
|
+
data_fetching = data_fetching.get("value", "server")
|
|
213
|
+
|
|
214
|
+
# Check auth requirement
|
|
215
|
+
auth_required = decisions.get("auth_required", {}).get("value", False)
|
|
216
|
+
if isinstance(auth_required, dict):
|
|
217
|
+
auth_required = auth_required.get("value", False)
|
|
218
|
+
|
|
219
|
+
# Route path (kebab-case)
|
|
220
|
+
route_path = element_name.lower().replace(" ", "-").replace("_", "-")
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"name": element_name.replace("-", " ").title(),
|
|
224
|
+
"description": description[:200] if description else f"Page: {element_name}",
|
|
225
|
+
"type": page_type,
|
|
226
|
+
"file": f"src/app/{route_path}/page.tsx",
|
|
227
|
+
"route": f"/{route_path}",
|
|
228
|
+
"tests": f"tests/e2e/{route_path}.spec.ts",
|
|
229
|
+
"uses_components": uses_components if isinstance(uses_components, list) else [],
|
|
230
|
+
"data_fetching": data_fetching,
|
|
231
|
+
"auth_required": auth_required,
|
|
232
|
+
"status": "complete",
|
|
233
|
+
"created_at": datetime.now().strftime("%Y-%m-%d")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_active_element(state):
|
|
238
|
+
"""Get active element - supports both API and UI workflows."""
|
|
239
|
+
# UI workflow format: elements object with active_element pointer
|
|
240
|
+
if "elements" in state and "active_element" in state:
|
|
241
|
+
active = state.get("active_element")
|
|
242
|
+
if active and active in state["elements"]:
|
|
243
|
+
return active, state["elements"][active]
|
|
244
|
+
|
|
245
|
+
# Fall back to API endpoint format
|
|
246
|
+
return get_active_endpoint(state)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main():
|
|
250
|
+
# Read hook input from stdin
|
|
251
|
+
try:
|
|
252
|
+
input_data = json.load(sys.stdin)
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
print(json.dumps({"continue": True}))
|
|
255
|
+
sys.exit(0)
|
|
256
|
+
|
|
257
|
+
tool_name = input_data.get("tool_name", "")
|
|
258
|
+
|
|
259
|
+
# Only process Write/Edit operations
|
|
260
|
+
if tool_name not in ["Write", "Edit"]:
|
|
261
|
+
print(json.dumps({"continue": True}))
|
|
262
|
+
sys.exit(0)
|
|
263
|
+
|
|
264
|
+
# Check if state file exists
|
|
265
|
+
if not STATE_FILE.exists():
|
|
266
|
+
print(json.dumps({"continue": True}))
|
|
267
|
+
sys.exit(0)
|
|
268
|
+
|
|
269
|
+
# Load state
|
|
270
|
+
try:
|
|
271
|
+
state = json.loads(STATE_FILE.read_text())
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
print(json.dumps({"continue": True}))
|
|
274
|
+
sys.exit(0)
|
|
275
|
+
|
|
276
|
+
# Determine workflow type
|
|
277
|
+
workflow = state.get("workflow", "api-create")
|
|
278
|
+
|
|
279
|
+
# Get active element based on workflow type
|
|
280
|
+
if workflow in ["ui-create-component", "ui-create-page"]:
|
|
281
|
+
element_name, element_state = get_active_element(state)
|
|
282
|
+
else:
|
|
283
|
+
element_name, element_state = get_active_endpoint(state)
|
|
284
|
+
|
|
285
|
+
if not element_name or not element_state:
|
|
286
|
+
print(json.dumps({"continue": True}))
|
|
287
|
+
sys.exit(0)
|
|
288
|
+
|
|
289
|
+
# Check if completion phase just became "complete"
|
|
290
|
+
phases = element_state.get("phases", state.get("phases", {}))
|
|
291
|
+
completion = phases.get("completion", {})
|
|
292
|
+
|
|
293
|
+
if completion.get("status") != "complete":
|
|
294
|
+
print(json.dumps({"continue": True}))
|
|
295
|
+
sys.exit(0)
|
|
296
|
+
|
|
297
|
+
# Check if already in registry (avoid duplicates)
|
|
298
|
+
registry = load_registry()
|
|
299
|
+
|
|
300
|
+
# Result object - may include notify for UI workflows
|
|
301
|
+
result = {"continue": True}
|
|
302
|
+
|
|
303
|
+
# Route to appropriate handler based on workflow
|
|
304
|
+
if workflow == "ui-create-component":
|
|
305
|
+
# Component workflow
|
|
306
|
+
if element_name in registry.get("components", {}):
|
|
307
|
+
print(json.dumps(result))
|
|
308
|
+
sys.exit(0)
|
|
309
|
+
|
|
310
|
+
entry = extract_component_entry(element_name, element_state, state)
|
|
311
|
+
registry.setdefault("components", {})[element_name] = entry
|
|
312
|
+
result["notify"] = f"🎨 View in UI Showcase: http://localhost:3000/ui-showcase"
|
|
313
|
+
|
|
314
|
+
elif workflow == "ui-create-page":
|
|
315
|
+
# Page workflow
|
|
316
|
+
if element_name in registry.get("pages", {}):
|
|
317
|
+
print(json.dumps(result))
|
|
318
|
+
sys.exit(0)
|
|
319
|
+
|
|
320
|
+
entry = extract_page_entry(element_name, element_state, state)
|
|
321
|
+
registry.setdefault("pages", {})[element_name] = entry
|
|
322
|
+
result["notify"] = f"🎨 View in UI Showcase: http://localhost:3000/ui-showcase"
|
|
323
|
+
|
|
324
|
+
elif workflow in ["combine-api", "combine-ui"]:
|
|
325
|
+
# Combined workflow
|
|
326
|
+
if element_name in registry.get("combined", {}):
|
|
327
|
+
print(json.dumps(result))
|
|
328
|
+
sys.exit(0)
|
|
329
|
+
|
|
330
|
+
entry = extract_combined_entry(element_name, element_state, state)
|
|
331
|
+
registry.setdefault("combined", {})[element_name] = entry
|
|
332
|
+
|
|
333
|
+
else:
|
|
334
|
+
# Default: API workflow
|
|
335
|
+
if element_name in registry.get("apis", {}):
|
|
336
|
+
print(json.dumps(result))
|
|
337
|
+
sys.exit(0)
|
|
338
|
+
|
|
339
|
+
entry = extract_api_entry(element_name, element_state, state)
|
|
340
|
+
registry.setdefault("apis", {})[element_name] = entry
|
|
341
|
+
result["notify"] = f"🔌 View in API Showcase: http://localhost:3000/api-showcase"
|
|
342
|
+
|
|
343
|
+
# Save registry
|
|
344
|
+
save_registry(registry)
|
|
345
|
+
|
|
346
|
+
# Return success (with optional notify for UI workflows)
|
|
347
|
+
print(json.dumps(result))
|
|
348
|
+
sys.exit(0)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__":
|
|
352
|
+
main()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Auto-update TESTING_CHECKLIST.md when tests pass.
|
|
4
|
+
|
|
5
|
+
Hook Type: PostToolUse (matcher: Bash)
|
|
6
|
+
|
|
7
|
+
Detects test pass patterns and updates the checklist file with:
|
|
8
|
+
- Test results (PASS/FAIL)
|
|
9
|
+
- Timestamp
|
|
10
|
+
- Comments
|
|
11
|
+
|
|
12
|
+
Works by:
|
|
13
|
+
1. Detecting test-related Bash commands
|
|
14
|
+
2. Parsing output for pass/fail patterns
|
|
15
|
+
3. Updating the corresponding checklist rows
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_tool_result():
|
|
27
|
+
"""Get the tool result from environment"""
|
|
28
|
+
result = os.environ.get("CLAUDE_TOOL_RESULT", "")
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_tool_input():
|
|
33
|
+
"""Get the tool input from environment"""
|
|
34
|
+
try:
|
|
35
|
+
input_json = os.environ.get("CLAUDE_TOOL_INPUT", "{}")
|
|
36
|
+
return json.loads(input_json)
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_test_type(command: str, output: str) -> dict:
|
|
42
|
+
"""Detect what type of test was run and if it passed"""
|
|
43
|
+
result = {
|
|
44
|
+
"is_test": False,
|
|
45
|
+
"test_type": None,
|
|
46
|
+
"passed": None,
|
|
47
|
+
"hook_name": None,
|
|
48
|
+
"details": None
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
command_lower = command.lower()
|
|
52
|
+
|
|
53
|
+
# Hook compilation test
|
|
54
|
+
if "python3" in command_lower and ".py" in command_lower:
|
|
55
|
+
if "hooks/" in command or ".claude/hooks/" in command:
|
|
56
|
+
result["is_test"] = True
|
|
57
|
+
result["test_type"] = "hook_compile"
|
|
58
|
+
# Extract hook name
|
|
59
|
+
match = re.search(r'(?:hooks/|\.claude/hooks/)([^/\s]+\.py)', command)
|
|
60
|
+
if match:
|
|
61
|
+
result["hook_name"] = match.group(1)
|
|
62
|
+
# Check for pass/fail
|
|
63
|
+
if "Traceback" in output or "Error" in output or "SyntaxError" in output:
|
|
64
|
+
result["passed"] = False
|
|
65
|
+
result["details"] = "Syntax/import error"
|
|
66
|
+
elif "exit code" in output.lower():
|
|
67
|
+
exit_match = re.search(r'exit code[:\s]+(\d+)', output.lower())
|
|
68
|
+
if exit_match:
|
|
69
|
+
result["passed"] = exit_match.group(1) == "0"
|
|
70
|
+
else:
|
|
71
|
+
result["passed"] = True
|
|
72
|
+
result["details"] = "Compiles"
|
|
73
|
+
|
|
74
|
+
# Hook enforcement test
|
|
75
|
+
if "python3" in command_lower and ("enforce" in command_lower or "verify" in command_lower):
|
|
76
|
+
result["is_test"] = True
|
|
77
|
+
result["test_type"] = "hook_enforcement"
|
|
78
|
+
match = re.search(r'(?:hooks/|\.claude/hooks/)([^/\s]+\.py)', command)
|
|
79
|
+
if match:
|
|
80
|
+
result["hook_name"] = match.group(1)
|
|
81
|
+
|
|
82
|
+
# Check for blocking behavior
|
|
83
|
+
if '"permissionDecision": "deny"' in output or "BLOCKED" in output:
|
|
84
|
+
result["passed"] = True
|
|
85
|
+
result["details"] = "BLOCKS correctly"
|
|
86
|
+
elif '"permissionDecision": "allow"' in output:
|
|
87
|
+
result["passed"] = True
|
|
88
|
+
result["details"] = "ALLOWS correctly"
|
|
89
|
+
elif '"continue": true' in output:
|
|
90
|
+
result["passed"] = True
|
|
91
|
+
result["details"] = "Continues"
|
|
92
|
+
|
|
93
|
+
# pnpm test
|
|
94
|
+
if "pnpm test" in command_lower or "npm test" in command_lower:
|
|
95
|
+
result["is_test"] = True
|
|
96
|
+
result["test_type"] = "unit_test"
|
|
97
|
+
if "PASS" in output or "passed" in output.lower():
|
|
98
|
+
result["passed"] = True
|
|
99
|
+
elif "FAIL" in output or "failed" in output.lower():
|
|
100
|
+
result["passed"] = False
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def update_checklist(hook_name: str, status: str, comment: str):
|
|
106
|
+
"""Update the TESTING_CHECKLIST.md file with test results"""
|
|
107
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
108
|
+
checklist_path = Path(project_dir) / "TESTING_CHECKLIST.md"
|
|
109
|
+
|
|
110
|
+
if not checklist_path.exists():
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
content = checklist_path.read_text()
|
|
115
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
116
|
+
|
|
117
|
+
# Pattern to find hook row in table (with empty Status column)
|
|
118
|
+
# Format: | `hook_name` | Type | Phase/Trigger | | |
|
|
119
|
+
pattern = rf'(\| `{re.escape(hook_name)}` \|[^|]+\|[^|]+\|)\s*\|\s*\|'
|
|
120
|
+
replacement = rf'\1 {status} | {comment} ({today}) |'
|
|
121
|
+
|
|
122
|
+
new_content = re.sub(pattern, replacement, content)
|
|
123
|
+
|
|
124
|
+
if new_content != content:
|
|
125
|
+
checklist_path.write_text(new_content)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Try alternate pattern for already-filled rows (update existing)
|
|
129
|
+
pattern2 = rf'(\| `{re.escape(hook_name)}` \|[^|]+\|[^|]+\|)[^|]+\|[^|]+\|'
|
|
130
|
+
replacement2 = rf'\1 {status} | {comment} ({today}) |'
|
|
131
|
+
|
|
132
|
+
new_content = re.sub(pattern2, replacement2, content)
|
|
133
|
+
if new_content != content:
|
|
134
|
+
checklist_path.write_text(new_content)
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
# Log error but don't fail
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
tool_input = get_tool_input()
|
|
146
|
+
command = tool_input.get("command", "")
|
|
147
|
+
output = get_tool_result()
|
|
148
|
+
|
|
149
|
+
# Detect what test was run
|
|
150
|
+
test_info = detect_test_type(command, output)
|
|
151
|
+
|
|
152
|
+
if not test_info["is_test"]:
|
|
153
|
+
print(json.dumps({"continue": True}))
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Update checklist if we have a hook name
|
|
157
|
+
if test_info["hook_name"] and test_info["passed"] is not None:
|
|
158
|
+
status = "PASS" if test_info["passed"] else "FAIL"
|
|
159
|
+
comment = test_info["details"] or ("Tested" if test_info["passed"] else "Failed")
|
|
160
|
+
|
|
161
|
+
updated = update_checklist(
|
|
162
|
+
test_info["hook_name"],
|
|
163
|
+
status,
|
|
164
|
+
comment
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if updated:
|
|
168
|
+
# Log the update
|
|
169
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
170
|
+
logs_dir = Path(project_dir) / ".claude" / "workflow-logs"
|
|
171
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
log_file = logs_dir / "checklist-updates.json"
|
|
174
|
+
try:
|
|
175
|
+
if log_file.exists():
|
|
176
|
+
log = json.loads(log_file.read_text())
|
|
177
|
+
else:
|
|
178
|
+
log = {"updates": []}
|
|
179
|
+
|
|
180
|
+
log["updates"].append({
|
|
181
|
+
"timestamp": datetime.now().isoformat(),
|
|
182
|
+
"hook": test_info["hook_name"],
|
|
183
|
+
"status": status,
|
|
184
|
+
"comment": comment
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
log_file.write_text(json.dumps(log, indent=2))
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
print(json.dumps({"continue": True}))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|