@hustle-together/api-dev-tools 3.11.1 → 3.12.2

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.
Files changed (139) hide show
  1. package/.claude/agents/code-reviewer.md +170 -0
  2. package/.claude/agents/docs-generator.md +80 -0
  3. package/.claude/agents/implementation-reviewer.md +119 -0
  4. package/.claude/agents/parallel-researcher.md +52 -0
  5. package/.claude/agents/research-validator.md +116 -0
  6. package/.claude/agents/schema-generator.md +70 -0
  7. package/.claude/agents/test-writer.md +104 -0
  8. package/.claude/api-dev-state.json +305 -56
  9. package/.claude/commands/README.md +21 -10
  10. package/.claude/commands/add-command.md +8 -5
  11. package/.claude/commands/api-create.md +36 -25
  12. package/.claude/commands/api-env.md +1 -0
  13. package/.claude/commands/api-interview.md +32 -19
  14. package/.claude/commands/api-research.md +47 -21
  15. package/.claude/commands/api-status.md +21 -1
  16. package/.claude/commands/api-verify.md +14 -13
  17. package/.claude/commands/beepboop.md +4 -5
  18. package/.claude/commands/busycommit.md +2 -3
  19. package/.claude/commands/commit.md +2 -3
  20. package/.claude/commands/cycle.md +2 -7
  21. package/.claude/commands/gap.md +2 -3
  22. package/.claude/commands/green.md +2 -7
  23. package/.claude/commands/issue.md +3 -8
  24. package/.claude/commands/ntfy-setup.md +91 -0
  25. package/.claude/commands/ntfy-test.md +74 -0
  26. package/.claude/commands/plan.md +2 -3
  27. package/.claude/commands/pr.md +2 -3
  28. package/.claude/commands/publish.md +40 -0
  29. package/.claude/commands/red.md +2 -7
  30. package/.claude/commands/refactor.md +2 -7
  31. package/.claude/commands/spike.md +2 -7
  32. package/.claude/commands/summarize.md +2 -3
  33. package/.claude/commands/tdd.md +2 -7
  34. package/.claude/commands/worktree-add.md +208 -216
  35. package/.claude/commands/worktree-cleanup.md +172 -178
  36. package/.claude/settings.json +63 -12
  37. package/.claude/settings.local.json +2 -1
  38. package/.claude-plugin/marketplace.json +2 -11
  39. package/.skills/README.md +55 -53
  40. package/.skills/_shared/settings.json +1 -1
  41. package/.skills/add-command/SKILL.md +10 -5
  42. package/.skills/api-create/SKILL.md +146 -35
  43. package/.skills/api-env/SKILL.md +1 -0
  44. package/.skills/api-interview/SKILL.md +32 -19
  45. package/.skills/api-research/SKILL.md +47 -21
  46. package/.skills/api-status/SKILL.md +21 -1
  47. package/.skills/api-verify/SKILL.md +14 -13
  48. package/.skills/beepboop/SKILL.md +6 -5
  49. package/.skills/busycommit/SKILL.md +4 -3
  50. package/.skills/commit/SKILL.md +4 -3
  51. package/.skills/cycle/SKILL.md +4 -7
  52. package/.skills/gap/SKILL.md +4 -3
  53. package/.skills/green/SKILL.md +4 -7
  54. package/.skills/issue/SKILL.md +5 -8
  55. package/.skills/plan/SKILL.md +4 -3
  56. package/.skills/pr/SKILL.md +4 -3
  57. package/.skills/publish/SKILL.md +160 -0
  58. package/.skills/red/SKILL.md +4 -7
  59. package/.skills/refactor/SKILL.md +4 -7
  60. package/.skills/spike/SKILL.md +4 -7
  61. package/.skills/summarize/SKILL.md +4 -3
  62. package/.skills/tdd/SKILL.md +4 -7
  63. package/.skills/update-todos/SKILL.md +22 -0
  64. package/.skills/worktree-add/SKILL.md +210 -216
  65. package/.skills/worktree-cleanup/SKILL.md +183 -187
  66. package/CHANGELOG.md +97 -79
  67. package/README.md +161 -7142
  68. package/bin/cli.js +448 -805
  69. package/commands/README.md +66 -31
  70. package/commands/add-command.md +8 -5
  71. package/commands/beepboop.md +4 -5
  72. package/commands/busycommit.md +2 -3
  73. package/commands/commit.md +2 -3
  74. package/commands/cycle.md +2 -7
  75. package/commands/gap.md +2 -3
  76. package/commands/green.md +2 -7
  77. package/commands/hustle-api-continue.md +8 -5
  78. package/commands/hustle-api-create.md +70 -29
  79. package/commands/hustle-api-env.md +1 -0
  80. package/commands/hustle-api-interview.md +32 -19
  81. package/commands/hustle-api-research.md +47 -21
  82. package/commands/hustle-api-sessions.md +8 -7
  83. package/commands/hustle-api-status.md +21 -1
  84. package/commands/hustle-api-verify.md +14 -13
  85. package/commands/hustle-combine.md +488 -241
  86. package/commands/hustle-ui-create-page.md +113 -50
  87. package/commands/hustle-ui-create.md +179 -26
  88. package/commands/issue.md +3 -8
  89. package/commands/plan.md +2 -3
  90. package/commands/pr.md +2 -3
  91. package/commands/red.md +2 -7
  92. package/commands/refactor.md +2 -7
  93. package/commands/spike.md +2 -7
  94. package/commands/summarize.md +2 -3
  95. package/commands/tdd.md +2 -7
  96. package/commands/worktree-add.md +208 -216
  97. package/commands/worktree-cleanup.md +172 -178
  98. package/hooks/api-workflow-check.py +5 -3
  99. package/hooks/enforce-component-type-confirm.py +97 -0
  100. package/hooks/lib/__init__.py +1 -0
  101. package/hooks/lib/greptile.py +355 -0
  102. package/hooks/lib/ntfy.py +209 -0
  103. package/hooks/notify-input-needed.py +73 -0
  104. package/hooks/notify-phase-complete.py +90 -0
  105. package/hooks/run-code-review.py +246 -0
  106. package/hooks/track-token-usage.py +121 -0
  107. package/package.json +13 -3
  108. package/scripts/collect-test-results.ts +102 -77
  109. package/scripts/extract-parameters.ts +112 -70
  110. package/scripts/generate-test-manifest.ts +118 -77
  111. package/templates/.env.example +57 -0
  112. package/templates/BRAND_GUIDE.md +92 -52
  113. package/templates/CLAUDE-SECTION.md +40 -37
  114. package/templates/SPEC.json +186 -38
  115. package/templates/api-dev-state.json +33 -4
  116. package/templates/api-showcase/_components/APICard.tsx +22 -18
  117. package/templates/api-showcase/_components/APIModal.tsx +110 -64
  118. package/templates/api-showcase/_components/APIShowcase.tsx +53 -35
  119. package/templates/api-showcase/_components/APITester.tsx +128 -67
  120. package/templates/api-showcase/page.tsx +4 -4
  121. package/templates/api-test/page.tsx +51 -30
  122. package/templates/api-test/test-structure/route.ts +43 -34
  123. package/templates/component/Component.stories.tsx +41 -39
  124. package/templates/component/Component.test.tsx +96 -78
  125. package/templates/component/Component.tsx +63 -52
  126. package/templates/component/Component.types.ts +10 -6
  127. package/templates/component/Component.visual.spec.ts +170 -0
  128. package/templates/component/index.ts +2 -2
  129. package/templates/dev-tools/_components/DevToolsLanding.tsx +8 -8
  130. package/templates/dev-tools/page.tsx +4 -3
  131. package/templates/mcp-servers.json +30 -2
  132. package/templates/page/page.e2e.test.ts +56 -48
  133. package/templates/page/page.tsx +3 -3
  134. package/templates/shared/HeroHeader.tsx +16 -15
  135. package/templates/shared/index.ts +1 -1
  136. package/templates/ui-showcase/_components/PreviewCard.tsx +20 -20
  137. package/templates/ui-showcase/_components/PreviewModal.tsx +149 -108
  138. package/templates/ui-showcase/_components/UIShowcase.tsx +43 -35
  139. package/templates/ui-showcase/page.tsx +4 -4
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Greptile Code Review Helper
4
+
5
+ Shared library for AI-powered code review via Greptile API.
6
+ Used in Phase 14 of the API development workflow.
7
+
8
+ Usage:
9
+ from lib.greptile import submit_review, check_review_status, get_review_feedback
10
+
11
+ Environment Variables:
12
+ GREPTILE_API_KEY: Your Greptile API key (get from https://app.greptile.com)
13
+ GITHUB_TOKEN: GitHub Personal Access Token with repo access
14
+
15
+ API Documentation:
16
+ https://docs.greptile.com/api-reference
17
+
18
+ Version: 1.0.0
19
+ """
20
+ import os
21
+ import json
22
+ import urllib.request
23
+ import urllib.error
24
+ from typing import Optional, Dict, List, Any
25
+ from pathlib import Path
26
+
27
+
28
+ # Greptile API base URL
29
+ GREPTILE_API_BASE = "https://api.greptile.com/v2"
30
+
31
+
32
+ def get_config() -> dict:
33
+ """Get Greptile configuration from environment or .env file."""
34
+ config = {
35
+ "api_key": os.environ.get("GREPTILE_API_KEY", ""),
36
+ "github_token": os.environ.get("GITHUB_TOKEN", ""),
37
+ "enabled": False,
38
+ }
39
+
40
+ # Try to read from .env if not in environment
41
+ if not config["api_key"] or not config["github_token"]:
42
+ env_file = Path.cwd() / ".env"
43
+ if env_file.exists():
44
+ try:
45
+ for line in env_file.read_text().splitlines():
46
+ line = line.strip()
47
+ if not line or line.startswith("#"):
48
+ continue
49
+ if "=" in line:
50
+ key, _, value = line.partition("=")
51
+ key = key.strip()
52
+ value = value.strip().strip('"').strip("'")
53
+ if key == "GREPTILE_API_KEY":
54
+ config["api_key"] = value
55
+ elif key == "GITHUB_TOKEN":
56
+ config["github_token"] = value
57
+ except IOError:
58
+ pass
59
+
60
+ config["enabled"] = bool(config["api_key"] and config["github_token"])
61
+ return config
62
+
63
+
64
+ def _make_request(
65
+ endpoint: str,
66
+ method: str = "GET",
67
+ data: Optional[dict] = None
68
+ ) -> Optional[dict]:
69
+ """Make authenticated request to Greptile API."""
70
+ config = get_config()
71
+
72
+ if not config["enabled"]:
73
+ return None
74
+
75
+ url = f"{GREPTILE_API_BASE}{endpoint}"
76
+
77
+ headers = {
78
+ "Authorization": f"Bearer {config['api_key']}",
79
+ "X-GitHub-Token": config["github_token"],
80
+ "Content-Type": "application/json",
81
+ }
82
+
83
+ body = None
84
+ if data:
85
+ body = json.dumps(data).encode("utf-8")
86
+
87
+ try:
88
+ req = urllib.request.Request(
89
+ url,
90
+ data=body,
91
+ headers=headers,
92
+ method=method
93
+ )
94
+ with urllib.request.urlopen(req, timeout=30) as response:
95
+ return json.loads(response.read().decode("utf-8"))
96
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
97
+ print(f"Greptile API error: {e}")
98
+ return None
99
+ except json.JSONDecodeError:
100
+ return None
101
+
102
+
103
+ def index_repository(
104
+ repo_owner: str,
105
+ repo_name: str,
106
+ branch: str = "main"
107
+ ) -> Optional[dict]:
108
+ """
109
+ Index a repository for Greptile to analyze.
110
+
111
+ Args:
112
+ repo_owner: GitHub repository owner
113
+ repo_name: GitHub repository name
114
+ branch: Branch to index (default: main)
115
+
116
+ Returns:
117
+ Repository status dict or None if failed
118
+ """
119
+ data = {
120
+ "remote": "github",
121
+ "repository": f"{repo_owner}/{repo_name}",
122
+ "branch": branch
123
+ }
124
+
125
+ return _make_request("/repositories", method="POST", data=data)
126
+
127
+
128
+ def query_codebase(
129
+ repo_owner: str,
130
+ repo_name: str,
131
+ question: str,
132
+ branch: str = "main"
133
+ ) -> Optional[dict]:
134
+ """
135
+ Query the codebase using natural language.
136
+
137
+ Args:
138
+ repo_owner: GitHub repository owner
139
+ repo_name: GitHub repository name
140
+ question: Natural language question about the codebase
141
+ branch: Branch to query (default: main)
142
+
143
+ Returns:
144
+ Query response with sources and answer
145
+ """
146
+ data = {
147
+ "messages": [
148
+ {"role": "user", "content": question}
149
+ ],
150
+ "repositories": [
151
+ {
152
+ "remote": "github",
153
+ "repository": f"{repo_owner}/{repo_name}",
154
+ "branch": branch
155
+ }
156
+ ],
157
+ "stream": False
158
+ }
159
+
160
+ return _make_request("/query", method="POST", data=data)
161
+
162
+
163
+ def review_changes(
164
+ repo_owner: str,
165
+ repo_name: str,
166
+ files_changed: List[str],
167
+ diff_content: str,
168
+ pr_number: Optional[int] = None
169
+ ) -> Optional[dict]:
170
+ """
171
+ Submit code changes for AI review.
172
+
173
+ Args:
174
+ repo_owner: GitHub repository owner
175
+ repo_name: GitHub repository name
176
+ files_changed: List of changed file paths
177
+ diff_content: The actual diff content
178
+ pr_number: Optional PR number for context
179
+
180
+ Returns:
181
+ Review results with suggestions and issues
182
+ """
183
+ review_prompt = f"""Review the following code changes for:
184
+ 1. Potential bugs or logic errors
185
+ 2. Security vulnerabilities (OWASP top 10)
186
+ 3. Performance issues
187
+ 4. Code quality and maintainability
188
+ 5. Adherence to TypeScript/JavaScript best practices
189
+
190
+ Files changed: {', '.join(files_changed)}
191
+
192
+ Provide specific, actionable feedback with file and line references.
193
+ """
194
+
195
+ data = {
196
+ "messages": [
197
+ {"role": "user", "content": review_prompt},
198
+ {"role": "user", "content": f"```diff\n{diff_content}\n```"}
199
+ ],
200
+ "repositories": [
201
+ {
202
+ "remote": "github",
203
+ "repository": f"{repo_owner}/{repo_name}",
204
+ "branch": "main"
205
+ }
206
+ ],
207
+ "stream": False
208
+ }
209
+
210
+ return _make_request("/query", method="POST", data=data)
211
+
212
+
213
+ def get_review_summary(review_result: dict) -> dict:
214
+ """
215
+ Parse Greptile review results into a structured summary.
216
+
217
+ Args:
218
+ review_result: Raw response from review_changes()
219
+
220
+ Returns:
221
+ Structured summary with issues, suggestions, and score
222
+ """
223
+ if not review_result:
224
+ return {
225
+ "success": False,
226
+ "error": "No review result received",
227
+ "issues": [],
228
+ "suggestions": [],
229
+ "score": 0
230
+ }
231
+
232
+ message = review_result.get("message", "")
233
+ sources = review_result.get("sources", [])
234
+
235
+ # Parse issues and suggestions from the response
236
+ issues = []
237
+ suggestions = []
238
+
239
+ # Simple parsing - look for common patterns
240
+ lines = message.split("\n")
241
+ for line in lines:
242
+ line_lower = line.lower()
243
+ if any(word in line_lower for word in ["bug", "error", "issue", "problem", "vulnerability"]):
244
+ issues.append(line.strip("- "))
245
+ elif any(word in line_lower for word in ["suggest", "consider", "recommend", "could", "should"]):
246
+ suggestions.append(line.strip("- "))
247
+
248
+ # Calculate a simple score
249
+ issue_count = len(issues)
250
+ if issue_count == 0:
251
+ score = 10
252
+ elif issue_count <= 2:
253
+ score = 8
254
+ elif issue_count <= 5:
255
+ score = 6
256
+ else:
257
+ score = 4
258
+
259
+ return {
260
+ "success": True,
261
+ "message": message,
262
+ "sources": sources,
263
+ "issues": issues,
264
+ "suggestions": suggestions,
265
+ "issue_count": issue_count,
266
+ "suggestion_count": len(suggestions),
267
+ "score": score
268
+ }
269
+
270
+
271
+ def format_review_for_display(summary: dict) -> str:
272
+ """
273
+ Format review summary for display in terminal.
274
+
275
+ Args:
276
+ summary: Parsed review summary from get_review_summary()
277
+
278
+ Returns:
279
+ Formatted string for display
280
+ """
281
+ if not summary.get("success"):
282
+ return f"Review failed: {summary.get('error', 'Unknown error')}"
283
+
284
+ output = []
285
+ output.append("=" * 60)
286
+ output.append("GREPTILE CODE REVIEW - Phase 14")
287
+ output.append("=" * 60)
288
+ output.append("")
289
+ output.append(f"Score: {summary['score']}/10")
290
+ output.append(f"Issues Found: {summary['issue_count']}")
291
+ output.append(f"Suggestions: {summary['suggestion_count']}")
292
+ output.append("")
293
+
294
+ if summary["issues"]:
295
+ output.append("ISSUES:")
296
+ for i, issue in enumerate(summary["issues"], 1):
297
+ output.append(f" {i}. {issue}")
298
+ output.append("")
299
+
300
+ if summary["suggestions"]:
301
+ output.append("SUGGESTIONS:")
302
+ for i, suggestion in enumerate(summary["suggestions"], 1):
303
+ output.append(f" {i}. {suggestion}")
304
+ output.append("")
305
+
306
+ output.append("-" * 60)
307
+ output.append("Full Review:")
308
+ output.append(summary.get("message", "No detailed message"))
309
+ output.append("=" * 60)
310
+
311
+ return "\n".join(output)
312
+
313
+
314
+ def is_configured() -> bool:
315
+ """Check if Greptile is properly configured."""
316
+ config = get_config()
317
+ return config["enabled"]
318
+
319
+
320
+ def get_status() -> dict:
321
+ """Get current Greptile configuration status."""
322
+ config = get_config()
323
+ return {
324
+ "enabled": config["enabled"],
325
+ "api_key_set": bool(config["api_key"]),
326
+ "github_token_set": bool(config["github_token"]),
327
+ "message": "Greptile is configured and ready" if config["enabled"]
328
+ else "Missing GREPTILE_API_KEY or GITHUB_TOKEN"
329
+ }
330
+
331
+
332
+ if __name__ == "__main__":
333
+ # Test configuration
334
+ import sys
335
+
336
+ status = get_status()
337
+ print(f"Greptile Status: {'Configured' if status['enabled'] else 'Not Configured'}")
338
+ print(f" API Key: {'Set' if status['api_key_set'] else 'Missing'}")
339
+ print(f" GitHub Token: {'Set' if status['github_token_set'] else 'Missing'}")
340
+
341
+ if len(sys.argv) > 1 and sys.argv[1] == "--test":
342
+ if status["enabled"]:
343
+ print("\nTesting codebase query...")
344
+ result = query_codebase(
345
+ "hustle-together",
346
+ "api-dev-tools",
347
+ "What is the main purpose of this repository?"
348
+ )
349
+ if result:
350
+ print("Query successful!")
351
+ print(result.get("message", "No message"))
352
+ else:
353
+ print("Query failed - check API credentials")
354
+ else:
355
+ print("\nConfigure GREPTILE_API_KEY and GITHUB_TOKEN to test")
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NTFY Notification Helper
4
+
5
+ Shared library for sending notifications via ntfy.sh
6
+
7
+ Usage:
8
+ from lib.ntfy import send_notification, send_phase_update
9
+
10
+ Environment Variables:
11
+ NTFY_ENABLED: Set to 'true' to enable notifications
12
+ NTFY_SERVER: Server URL (default: https://ntfy.sh)
13
+ NTFY_TOPIC: Your unique topic name
14
+
15
+ Version: 3.10.0
16
+ """
17
+ import os
18
+ import json
19
+ import urllib.request
20
+ import urllib.error
21
+ from typing import Optional
22
+ from pathlib import Path
23
+
24
+
25
+ def get_config() -> dict:
26
+ """Get NTFY configuration from environment or .env file."""
27
+ config = {
28
+ "enabled": os.environ.get("NTFY_ENABLED", "false").lower() == "true",
29
+ "server": os.environ.get("NTFY_SERVER", "https://ntfy.sh"),
30
+ "topic": os.environ.get("NTFY_TOPIC", ""),
31
+ }
32
+
33
+ # Try to read from .env if not in environment
34
+ if not config["topic"]:
35
+ env_file = Path.cwd() / ".env"
36
+ if env_file.exists():
37
+ try:
38
+ for line in env_file.read_text().splitlines():
39
+ if line.startswith("NTFY_"):
40
+ key, _, value = line.partition("=")
41
+ key = key.strip()
42
+ value = value.strip().strip('"').strip("'")
43
+ if key == "NTFY_ENABLED":
44
+ config["enabled"] = value.lower() == "true"
45
+ elif key == "NTFY_SERVER":
46
+ config["server"] = value
47
+ elif key == "NTFY_TOPIC":
48
+ config["topic"] = value
49
+ except IOError:
50
+ pass
51
+
52
+ return config
53
+
54
+
55
+ def send_notification(
56
+ message: str,
57
+ title: Optional[str] = None,
58
+ priority: str = "default",
59
+ tags: Optional[list] = None,
60
+ include_tokens: bool = True
61
+ ) -> bool:
62
+ """
63
+ Send a notification via NTFY.
64
+
65
+ Args:
66
+ message: The notification message
67
+ title: Optional title
68
+ priority: One of: min, low, default, high, urgent
69
+ tags: List of emoji tags (e.g., ["rocket", "white_check_mark"])
70
+ include_tokens: Whether to include token usage in message
71
+
72
+ Returns:
73
+ True if sent successfully, False otherwise
74
+ """
75
+ config = get_config()
76
+
77
+ if not config["enabled"] or not config["topic"]:
78
+ return False
79
+
80
+ url = f"{config['server']}/{config['topic']}"
81
+
82
+ # Build message with optional token info
83
+ full_message = message
84
+ if include_tokens:
85
+ token_info = get_token_usage()
86
+ if token_info:
87
+ full_message += f"\n\n📊 Tokens: {token_info}"
88
+
89
+ headers = {
90
+ "Content-Type": "text/plain; charset=utf-8",
91
+ "Priority": priority,
92
+ }
93
+
94
+ if title:
95
+ headers["Title"] = title
96
+
97
+ if tags:
98
+ headers["Tags"] = ",".join(tags)
99
+
100
+ try:
101
+ req = urllib.request.Request(
102
+ url,
103
+ data=full_message.encode("utf-8"),
104
+ headers=headers,
105
+ method="POST"
106
+ )
107
+ with urllib.request.urlopen(req, timeout=5) as response:
108
+ return response.status == 200
109
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
110
+ return False
111
+
112
+
113
+ def send_phase_update(
114
+ phase_name: str,
115
+ status: str,
116
+ details: Optional[str] = None,
117
+ workflow: str = "api-create"
118
+ ) -> bool:
119
+ """
120
+ Send a phase completion notification.
121
+
122
+ Args:
123
+ phase_name: Name of the phase (e.g., "Research", "Interview")
124
+ status: One of "started", "complete", "blocked", "needs_input"
125
+ details: Optional additional details
126
+ workflow: The workflow name
127
+ """
128
+ status_emoji = {
129
+ "started": "🔄",
130
+ "complete": "✅",
131
+ "blocked": "🚫",
132
+ "needs_input": "⏳",
133
+ }
134
+
135
+ emoji = status_emoji.get(status, "📋")
136
+ title = f"{emoji} {phase_name} - {status.replace('_', ' ').title()}"
137
+
138
+ message = f"Workflow: {workflow}"
139
+ if details:
140
+ message += f"\n{details}"
141
+
142
+ priority = "high" if status == "needs_input" else "default"
143
+ tags = ["bell"] if status == "needs_input" else ["clipboard"]
144
+
145
+ return send_notification(
146
+ message=message,
147
+ title=title,
148
+ priority=priority,
149
+ tags=tags
150
+ )
151
+
152
+
153
+ def send_input_needed(
154
+ question: str,
155
+ options: Optional[list] = None,
156
+ phase: str = "Interview"
157
+ ) -> bool:
158
+ """
159
+ Send notification that user input is needed.
160
+
161
+ Args:
162
+ question: The question being asked
163
+ options: Optional list of available options
164
+ phase: The current phase
165
+ """
166
+ message = f"Question: {question}"
167
+ if options:
168
+ message += "\n\nOptions:\n" + "\n".join(f" • {opt}" for opt in options)
169
+
170
+ return send_notification(
171
+ message=message,
172
+ title=f"⏳ Input Needed - {phase}",
173
+ priority="high",
174
+ tags=["question", "bell"]
175
+ )
176
+
177
+
178
+ def get_token_usage() -> Optional[str]:
179
+ """Get current token usage from ccusage if available."""
180
+ try:
181
+ import subprocess
182
+ result = subprocess.run(
183
+ ["ccusage", "--json"],
184
+ capture_output=True,
185
+ text=True,
186
+ timeout=5
187
+ )
188
+ if result.returncode == 0:
189
+ data = json.loads(result.stdout)
190
+ total = data.get("total_tokens", 0)
191
+ cost = data.get("total_cost", 0)
192
+ if total:
193
+ return f"{total:,} tokens (${cost:.2f})"
194
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
195
+ pass
196
+ return None
197
+
198
+
199
+ if __name__ == "__main__":
200
+ # Test notification
201
+ import sys
202
+ if len(sys.argv) > 1:
203
+ message = " ".join(sys.argv[1:])
204
+ if send_notification(message, title="Test Notification", tags=["test"]):
205
+ print("✅ Notification sent!")
206
+ else:
207
+ print("❌ Failed to send (check NTFY_ENABLED and NTFY_TOPIC)")
208
+ else:
209
+ print("Usage: python ntfy.py <message>")
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Hook: PreToolUse for AskUserQuestion
4
+ Purpose: Send NTFY notification when user input is needed
5
+
6
+ Triggers before AskUserQuestion tool is called.
7
+ Sends push notification so user knows to check Claude Code.
8
+
9
+ Version: 3.10.0
10
+ """
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # Add lib to path
16
+ sys.path.insert(0, str(Path(__file__).parent))
17
+ from lib.ntfy import send_input_needed
18
+
19
+
20
+ def main():
21
+ # Read hook input from stdin
22
+ try:
23
+ input_data = json.load(sys.stdin)
24
+ except json.JSONDecodeError:
25
+ # Always allow the tool to proceed
26
+ print(json.dumps({"continue": True}))
27
+ sys.exit(0)
28
+
29
+ tool_name = input_data.get("tool_name", "")
30
+ tool_input = input_data.get("tool_input", {})
31
+
32
+ # Only trigger on AskUserQuestion
33
+ if tool_name != "AskUserQuestion":
34
+ print(json.dumps({"continue": True}))
35
+ sys.exit(0)
36
+
37
+ # Extract question info
38
+ questions = tool_input.get("questions", [])
39
+ if not questions:
40
+ print(json.dumps({"continue": True}))
41
+ sys.exit(0)
42
+
43
+ # Get first question details
44
+ q = questions[0]
45
+ question_text = q.get("question", "Input needed")
46
+ header = q.get("header", "Question")
47
+ options = q.get("options", [])
48
+
49
+ # Extract option labels
50
+ option_labels = []
51
+ for opt in options:
52
+ if isinstance(opt, dict):
53
+ option_labels.append(opt.get("label", str(opt)))
54
+ else:
55
+ option_labels.append(str(opt))
56
+
57
+ # Determine phase from header
58
+ phase = header if len(header) <= 20 else "Interview"
59
+
60
+ # Send notification
61
+ send_input_needed(
62
+ question=question_text,
63
+ options=option_labels if option_labels else None,
64
+ phase=phase
65
+ )
66
+
67
+ # Always allow the tool to proceed
68
+ print(json.dumps({"continue": True}))
69
+ sys.exit(0)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()