@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.
- package/.claude/agents/code-reviewer.md +170 -0
- package/.claude/agents/docs-generator.md +80 -0
- package/.claude/agents/implementation-reviewer.md +119 -0
- package/.claude/agents/parallel-researcher.md +52 -0
- package/.claude/agents/research-validator.md +116 -0
- package/.claude/agents/schema-generator.md +70 -0
- package/.claude/agents/test-writer.md +104 -0
- package/.claude/api-dev-state.json +305 -56
- package/.claude/commands/README.md +21 -10
- package/.claude/commands/add-command.md +8 -5
- package/.claude/commands/api-create.md +36 -25
- package/.claude/commands/api-env.md +1 -0
- package/.claude/commands/api-interview.md +32 -19
- package/.claude/commands/api-research.md +47 -21
- package/.claude/commands/api-status.md +21 -1
- package/.claude/commands/api-verify.md +14 -13
- package/.claude/commands/beepboop.md +4 -5
- package/.claude/commands/busycommit.md +2 -3
- package/.claude/commands/commit.md +2 -3
- package/.claude/commands/cycle.md +2 -7
- package/.claude/commands/gap.md +2 -3
- package/.claude/commands/green.md +2 -7
- package/.claude/commands/issue.md +3 -8
- package/.claude/commands/ntfy-setup.md +91 -0
- package/.claude/commands/ntfy-test.md +74 -0
- package/.claude/commands/plan.md +2 -3
- package/.claude/commands/pr.md +2 -3
- package/.claude/commands/publish.md +40 -0
- package/.claude/commands/red.md +2 -7
- package/.claude/commands/refactor.md +2 -7
- package/.claude/commands/spike.md +2 -7
- package/.claude/commands/summarize.md +2 -3
- package/.claude/commands/tdd.md +2 -7
- package/.claude/commands/worktree-add.md +208 -216
- package/.claude/commands/worktree-cleanup.md +172 -178
- package/.claude/settings.json +63 -12
- package/.claude/settings.local.json +2 -1
- package/.claude-plugin/marketplace.json +2 -11
- package/.skills/README.md +55 -53
- package/.skills/_shared/settings.json +1 -1
- package/.skills/add-command/SKILL.md +10 -5
- package/.skills/api-create/SKILL.md +146 -35
- package/.skills/api-env/SKILL.md +1 -0
- package/.skills/api-interview/SKILL.md +32 -19
- package/.skills/api-research/SKILL.md +47 -21
- package/.skills/api-status/SKILL.md +21 -1
- package/.skills/api-verify/SKILL.md +14 -13
- package/.skills/beepboop/SKILL.md +6 -5
- package/.skills/busycommit/SKILL.md +4 -3
- package/.skills/commit/SKILL.md +4 -3
- package/.skills/cycle/SKILL.md +4 -7
- package/.skills/gap/SKILL.md +4 -3
- package/.skills/green/SKILL.md +4 -7
- package/.skills/issue/SKILL.md +5 -8
- package/.skills/plan/SKILL.md +4 -3
- package/.skills/pr/SKILL.md +4 -3
- package/.skills/publish/SKILL.md +160 -0
- package/.skills/red/SKILL.md +4 -7
- package/.skills/refactor/SKILL.md +4 -7
- package/.skills/spike/SKILL.md +4 -7
- package/.skills/summarize/SKILL.md +4 -3
- package/.skills/tdd/SKILL.md +4 -7
- package/.skills/update-todos/SKILL.md +22 -0
- package/.skills/worktree-add/SKILL.md +210 -216
- package/.skills/worktree-cleanup/SKILL.md +183 -187
- package/CHANGELOG.md +97 -79
- package/README.md +161 -7142
- package/bin/cli.js +448 -805
- package/commands/README.md +66 -31
- package/commands/add-command.md +8 -5
- package/commands/beepboop.md +4 -5
- package/commands/busycommit.md +2 -3
- package/commands/commit.md +2 -3
- package/commands/cycle.md +2 -7
- package/commands/gap.md +2 -3
- package/commands/green.md +2 -7
- package/commands/hustle-api-continue.md +8 -5
- package/commands/hustle-api-create.md +70 -29
- package/commands/hustle-api-env.md +1 -0
- package/commands/hustle-api-interview.md +32 -19
- package/commands/hustle-api-research.md +47 -21
- package/commands/hustle-api-sessions.md +8 -7
- package/commands/hustle-api-status.md +21 -1
- package/commands/hustle-api-verify.md +14 -13
- package/commands/hustle-combine.md +488 -241
- package/commands/hustle-ui-create-page.md +113 -50
- package/commands/hustle-ui-create.md +179 -26
- package/commands/issue.md +3 -8
- package/commands/plan.md +2 -3
- package/commands/pr.md +2 -3
- package/commands/red.md +2 -7
- package/commands/refactor.md +2 -7
- package/commands/spike.md +2 -7
- package/commands/summarize.md +2 -3
- package/commands/tdd.md +2 -7
- package/commands/worktree-add.md +208 -216
- package/commands/worktree-cleanup.md +172 -178
- package/hooks/api-workflow-check.py +5 -3
- package/hooks/enforce-component-type-confirm.py +97 -0
- package/hooks/lib/__init__.py +1 -0
- package/hooks/lib/greptile.py +355 -0
- package/hooks/lib/ntfy.py +209 -0
- package/hooks/notify-input-needed.py +73 -0
- package/hooks/notify-phase-complete.py +90 -0
- package/hooks/run-code-review.py +246 -0
- package/hooks/track-token-usage.py +121 -0
- package/package.json +13 -3
- package/scripts/collect-test-results.ts +102 -77
- package/scripts/extract-parameters.ts +112 -70
- package/scripts/generate-test-manifest.ts +118 -77
- package/templates/.env.example +57 -0
- package/templates/BRAND_GUIDE.md +92 -52
- package/templates/CLAUDE-SECTION.md +40 -37
- package/templates/SPEC.json +186 -38
- package/templates/api-dev-state.json +33 -4
- package/templates/api-showcase/_components/APICard.tsx +22 -18
- package/templates/api-showcase/_components/APIModal.tsx +110 -64
- package/templates/api-showcase/_components/APIShowcase.tsx +53 -35
- package/templates/api-showcase/_components/APITester.tsx +128 -67
- package/templates/api-showcase/page.tsx +4 -4
- package/templates/api-test/page.tsx +51 -30
- package/templates/api-test/test-structure/route.ts +43 -34
- package/templates/component/Component.stories.tsx +41 -39
- package/templates/component/Component.test.tsx +96 -78
- package/templates/component/Component.tsx +63 -52
- package/templates/component/Component.types.ts +10 -6
- package/templates/component/Component.visual.spec.ts +170 -0
- package/templates/component/index.ts +2 -2
- package/templates/dev-tools/_components/DevToolsLanding.tsx +8 -8
- package/templates/dev-tools/page.tsx +4 -3
- package/templates/mcp-servers.json +30 -2
- package/templates/page/page.e2e.test.ts +56 -48
- package/templates/page/page.tsx +3 -3
- package/templates/shared/HeroHeader.tsx +16 -15
- package/templates/shared/index.ts +1 -1
- package/templates/ui-showcase/_components/PreviewCard.tsx +20 -20
- package/templates/ui-showcase/_components/PreviewModal.tsx +149 -108
- package/templates/ui-showcase/_components/UIShowcase.tsx +43 -35
- 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()
|