@hasna/hooks 0.0.6 → 0.1.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/.claude/settings.json +24 -0
- package/bin/index.js +758 -319
- package/dist/index.js +156 -1
- package/hooks/hook-autoformat/README.md +39 -0
- package/hooks/hook-autoformat/package.json +58 -0
- package/hooks/hook-autoformat/src/hook.ts +223 -0
- package/hooks/hook-autostage/README.md +70 -0
- package/hooks/hook-autostage/package.json +12 -0
- package/hooks/hook-autostage/src/hook.ts +167 -0
- package/hooks/hook-commandlog/README.md +45 -0
- package/hooks/hook-commandlog/package.json +12 -0
- package/hooks/hook-commandlog/src/hook.ts +92 -0
- package/hooks/hook-costwatch/README.md +61 -0
- package/hooks/hook-costwatch/package.json +12 -0
- package/hooks/hook-costwatch/src/hook.ts +178 -0
- package/hooks/hook-desktopnotify/README.md +50 -0
- package/hooks/hook-desktopnotify/package.json +57 -0
- package/hooks/hook-desktopnotify/src/hook.ts +112 -0
- package/hooks/hook-envsetup/README.md +40 -0
- package/hooks/hook-envsetup/package.json +58 -0
- package/hooks/hook-envsetup/src/hook.ts +197 -0
- package/hooks/hook-errornotify/README.md +66 -0
- package/hooks/hook-errornotify/package.json +12 -0
- package/hooks/hook-errornotify/src/hook.ts +197 -0
- package/hooks/hook-permissionguard/README.md +48 -0
- package/hooks/hook-permissionguard/package.json +58 -0
- package/hooks/hook-permissionguard/src/hook.ts +268 -0
- package/hooks/hook-promptguard/README.md +64 -0
- package/hooks/hook-promptguard/package.json +12 -0
- package/hooks/hook-promptguard/src/hook.ts +200 -0
- package/hooks/hook-protectfiles/README.md +62 -0
- package/hooks/hook-protectfiles/package.json +58 -0
- package/hooks/hook-protectfiles/src/hook.ts +267 -0
- package/hooks/hook-sessionlog/README.md +48 -0
- package/hooks/hook-sessionlog/package.json +12 -0
- package/hooks/hook-sessionlog/src/hook.ts +100 -0
- package/hooks/hook-slacknotify/README.md +62 -0
- package/hooks/hook-slacknotify/package.json +12 -0
- package/hooks/hook-slacknotify/src/hook.ts +146 -0
- package/hooks/hook-soundnotify/README.md +63 -0
- package/hooks/hook-soundnotify/package.json +12 -0
- package/hooks/hook-soundnotify/src/hook.ts +173 -0
- package/hooks/hook-taskgate/README.md +62 -0
- package/hooks/hook-taskgate/package.json +12 -0
- package/hooks/hook-taskgate/src/hook.ts +169 -0
- package/hooks/hook-tddguard/README.md +50 -0
- package/hooks/hook-tddguard/package.json +12 -0
- package/hooks/hook-tddguard/src/hook.ts +263 -0
- package/package.json +4 -3
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: tddguard
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse hook that enforces TDD by blocking implementation file edits
|
|
7
|
+
* unless a corresponding test file exists in the project.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Test files (*.test.ts, *.spec.ts, *_test.py, test_*.py, *_test.go) → always approve
|
|
11
|
+
* - Config/non-code files (*.json, *.md, *.yml, etc.) → always approve
|
|
12
|
+
* - Implementation files → check if a corresponding test file exists; block if not
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from "fs";
|
|
16
|
+
import { basename, dirname, join, extname } from "path";
|
|
17
|
+
|
|
18
|
+
interface HookInput {
|
|
19
|
+
session_id: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
tool_name: string;
|
|
22
|
+
tool_input: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HookOutput {
|
|
26
|
+
decision: "approve" | "block";
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** File extensions that never need tests */
|
|
31
|
+
const SKIP_EXTENSIONS = new Set([
|
|
32
|
+
".json",
|
|
33
|
+
".md",
|
|
34
|
+
".yml",
|
|
35
|
+
".yaml",
|
|
36
|
+
".toml",
|
|
37
|
+
".css",
|
|
38
|
+
".scss",
|
|
39
|
+
".less",
|
|
40
|
+
".html",
|
|
41
|
+
".svg",
|
|
42
|
+
".png",
|
|
43
|
+
".jpg",
|
|
44
|
+
".jpeg",
|
|
45
|
+
".gif",
|
|
46
|
+
".ico",
|
|
47
|
+
".lock",
|
|
48
|
+
".txt",
|
|
49
|
+
".env",
|
|
50
|
+
".gitignore",
|
|
51
|
+
".dockerignore",
|
|
52
|
+
".editorconfig",
|
|
53
|
+
".prettierrc",
|
|
54
|
+
".eslintrc",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** File basenames that never need tests */
|
|
58
|
+
const SKIP_BASENAMES = new Set([
|
|
59
|
+
"package.json",
|
|
60
|
+
"tsconfig.json",
|
|
61
|
+
"biome.json",
|
|
62
|
+
"vite.config.ts",
|
|
63
|
+
"vite.config.js",
|
|
64
|
+
"next.config.js",
|
|
65
|
+
"next.config.mjs",
|
|
66
|
+
"next.config.ts",
|
|
67
|
+
"tailwind.config.js",
|
|
68
|
+
"tailwind.config.ts",
|
|
69
|
+
"postcss.config.js",
|
|
70
|
+
"postcss.config.mjs",
|
|
71
|
+
"jest.config.ts",
|
|
72
|
+
"jest.config.js",
|
|
73
|
+
"vitest.config.ts",
|
|
74
|
+
"vitest.config.js",
|
|
75
|
+
"Makefile",
|
|
76
|
+
"Dockerfile",
|
|
77
|
+
"docker-compose.yml",
|
|
78
|
+
"docker-compose.yaml",
|
|
79
|
+
".babelrc",
|
|
80
|
+
".env.example",
|
|
81
|
+
".env.local",
|
|
82
|
+
"CLAUDE.md",
|
|
83
|
+
"README.md",
|
|
84
|
+
"LICENSE",
|
|
85
|
+
"CHANGELOG.md",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** Patterns that identify a test file */
|
|
89
|
+
const TEST_FILE_PATTERNS: RegExp[] = [
|
|
90
|
+
/\.test\.[jt]sx?$/, // *.test.ts, *.test.js, *.test.tsx, *.test.jsx
|
|
91
|
+
/\.spec\.[jt]sx?$/, // *.spec.ts, *.spec.js, *.spec.tsx, *.spec.jsx
|
|
92
|
+
/_test\.py$/, // *_test.py
|
|
93
|
+
/^test_.*\.py$/, // test_*.py
|
|
94
|
+
/_test\.go$/, // *_test.go
|
|
95
|
+
/\.test\.go$/, // *.test.go (less common but valid)
|
|
96
|
+
/Test\.java$/, // *Test.java
|
|
97
|
+
/_test\.rb$/, // *_test.rb
|
|
98
|
+
/\.test\.rb$/, // *.test.rb
|
|
99
|
+
/_spec\.rb$/, // *_spec.rb
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function readStdinJson(): HookInput | null {
|
|
103
|
+
try {
|
|
104
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
105
|
+
if (!input) return null;
|
|
106
|
+
return JSON.parse(input);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function respond(output: HookOutput): void {
|
|
113
|
+
console.log(JSON.stringify(output));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isTestFile(filePath: string): boolean {
|
|
117
|
+
const name = basename(filePath);
|
|
118
|
+
return TEST_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shouldSkipFile(filePath: string): boolean {
|
|
122
|
+
const name = basename(filePath);
|
|
123
|
+
const ext = extname(filePath).toLowerCase();
|
|
124
|
+
|
|
125
|
+
// Skip config and non-code files
|
|
126
|
+
if (SKIP_EXTENSIONS.has(ext)) return true;
|
|
127
|
+
if (SKIP_BASENAMES.has(name)) return true;
|
|
128
|
+
|
|
129
|
+
// Skip files in common config/non-code directories
|
|
130
|
+
const lowerPath = filePath.toLowerCase();
|
|
131
|
+
if (lowerPath.includes("/node_modules/")) return true;
|
|
132
|
+
if (lowerPath.includes("/.claude/")) return true;
|
|
133
|
+
if (lowerPath.includes("/dist/")) return true;
|
|
134
|
+
if (lowerPath.includes("/build/")) return true;
|
|
135
|
+
if (lowerPath.includes("/.git/")) return true;
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate possible test file paths for a given implementation file.
|
|
142
|
+
* Checks the same directory, __tests__/, tests/, and test/ subdirectories.
|
|
143
|
+
*/
|
|
144
|
+
function getPossibleTestPaths(filePath: string, cwd: string): string[] {
|
|
145
|
+
const dir = dirname(filePath);
|
|
146
|
+
const name = basename(filePath);
|
|
147
|
+
const ext = extname(filePath);
|
|
148
|
+
const nameWithoutExt = name.slice(0, name.length - ext.length);
|
|
149
|
+
|
|
150
|
+
const testPaths: string[] = [];
|
|
151
|
+
const resolvedDir = filePath.startsWith("/") ? dir : join(cwd, dir);
|
|
152
|
+
|
|
153
|
+
// TypeScript/JavaScript patterns
|
|
154
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
155
|
+
const testExts = ext.includes("x") ? [ext] : [ext];
|
|
156
|
+
for (const testExt of testExts) {
|
|
157
|
+
// Same directory: foo.test.ts, foo.spec.ts
|
|
158
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}.test${testExt}`));
|
|
159
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}.spec${testExt}`));
|
|
160
|
+
// __tests__/ directory
|
|
161
|
+
testPaths.push(join(resolvedDir, "__tests__", `${nameWithoutExt}.test${testExt}`));
|
|
162
|
+
testPaths.push(join(resolvedDir, "__tests__", `${nameWithoutExt}.spec${testExt}`));
|
|
163
|
+
// tests/ directory (sibling)
|
|
164
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}.test${testExt}`));
|
|
165
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}.spec${testExt}`));
|
|
166
|
+
// Parent __tests__/
|
|
167
|
+
testPaths.push(join(resolvedDir, "..", "__tests__", `${nameWithoutExt}.test${testExt}`));
|
|
168
|
+
testPaths.push(join(resolvedDir, "..", "__tests__", `${nameWithoutExt}.spec${testExt}`));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Python patterns
|
|
173
|
+
if (ext === ".py") {
|
|
174
|
+
// Same directory: test_foo.py, foo_test.py
|
|
175
|
+
testPaths.push(join(resolvedDir, `test_${name}`));
|
|
176
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.py`));
|
|
177
|
+
// tests/ directory
|
|
178
|
+
testPaths.push(join(resolvedDir, "tests", `test_${name}`));
|
|
179
|
+
testPaths.push(join(resolvedDir, "tests", `${nameWithoutExt}_test.py`));
|
|
180
|
+
// Parent tests/
|
|
181
|
+
testPaths.push(join(resolvedDir, "..", "tests", `test_${name}`));
|
|
182
|
+
testPaths.push(join(resolvedDir, "..", "tests", `${nameWithoutExt}_test.py`));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Go patterns
|
|
186
|
+
if (ext === ".go") {
|
|
187
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.go`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Java patterns
|
|
191
|
+
if (ext === ".java") {
|
|
192
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}Test.java`));
|
|
193
|
+
// Common Maven/Gradle structure: src/test/java mirrors src/main/java
|
|
194
|
+
const testDir = resolvedDir.replace("/src/main/", "/src/test/");
|
|
195
|
+
if (testDir !== resolvedDir) {
|
|
196
|
+
testPaths.push(join(testDir, `${nameWithoutExt}Test.java`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Ruby patterns
|
|
201
|
+
if (ext === ".rb") {
|
|
202
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_test.rb`));
|
|
203
|
+
testPaths.push(join(resolvedDir, `${nameWithoutExt}_spec.rb`));
|
|
204
|
+
testPaths.push(join(resolvedDir, "test", `${nameWithoutExt}_test.rb`));
|
|
205
|
+
testPaths.push(join(resolvedDir, "spec", `${nameWithoutExt}_spec.rb`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return testPaths;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasCorrespondingTest(filePath: string, cwd: string): boolean {
|
|
212
|
+
const possiblePaths = getPossibleTestPaths(filePath, cwd);
|
|
213
|
+
return possiblePaths.some((testPath) => existsSync(testPath));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getFilePath(toolInput: Record<string, unknown>): string | null {
|
|
217
|
+
return (toolInput.file_path as string) || null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function run(): void {
|
|
221
|
+
const input = readStdinJson();
|
|
222
|
+
|
|
223
|
+
if (!input) {
|
|
224
|
+
respond({ decision: "approve" });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const filePath = getFilePath(input.tool_input);
|
|
229
|
+
if (!filePath) {
|
|
230
|
+
respond({ decision: "approve" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Always approve test files
|
|
235
|
+
if (isTestFile(filePath)) {
|
|
236
|
+
respond({ decision: "approve" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Skip files that don't need tests
|
|
241
|
+
if (shouldSkipFile(filePath)) {
|
|
242
|
+
respond({ decision: "approve" });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check if a corresponding test file exists
|
|
247
|
+
if (hasCorrespondingTest(filePath, input.cwd)) {
|
|
248
|
+
respond({ decision: "approve" });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// No test file found — block the edit
|
|
253
|
+
const name = basename(filePath);
|
|
254
|
+
console.error(`[hook-tddguard] No test file found for ${name}. Write tests first (TDD).`);
|
|
255
|
+
respond({
|
|
256
|
+
decision: "block",
|
|
257
|
+
reason: `Write tests first (TDD). No test file found for "${name}". Create a test file before editing implementation code.`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (import.meta.main) {
|
|
262
|
+
run();
|
|
263
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Open source Claude Code hooks library - Install hooks with a single command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"main": "./dist/index.js",
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"scripts": {
|
|
18
|
-
"build": "bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk --external conf && bun build ./src/index.ts --outdir ./dist --target bun",
|
|
18
|
+
"build": "bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk --external conf --external @modelcontextprotocol/sdk --external zod && bun build ./src/index.ts --outdir ./dist --target bun",
|
|
19
19
|
"dev": "bun run ./src/cli/index.tsx",
|
|
20
20
|
"test": "bun test",
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"typescript": "^5"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
42
43
|
"chalk": "^5.3.0",
|
|
43
44
|
"commander": "^12.1.0",
|
|
44
45
|
"conf": "^13.0.1",
|
|
@@ -57,6 +58,6 @@
|
|
|
57
58
|
},
|
|
58
59
|
"repository": {
|
|
59
60
|
"type": "git",
|
|
60
|
-
"url": "git+https://github.com/hasna/
|
|
61
|
+
"url": "git+https://github.com/hasna/hooks.git"
|
|
61
62
|
}
|
|
62
63
|
}
|