@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.
Files changed (49) hide show
  1. package/.claude/settings.json +24 -0
  2. package/bin/index.js +758 -319
  3. package/dist/index.js +156 -1
  4. package/hooks/hook-autoformat/README.md +39 -0
  5. package/hooks/hook-autoformat/package.json +58 -0
  6. package/hooks/hook-autoformat/src/hook.ts +223 -0
  7. package/hooks/hook-autostage/README.md +70 -0
  8. package/hooks/hook-autostage/package.json +12 -0
  9. package/hooks/hook-autostage/src/hook.ts +167 -0
  10. package/hooks/hook-commandlog/README.md +45 -0
  11. package/hooks/hook-commandlog/package.json +12 -0
  12. package/hooks/hook-commandlog/src/hook.ts +92 -0
  13. package/hooks/hook-costwatch/README.md +61 -0
  14. package/hooks/hook-costwatch/package.json +12 -0
  15. package/hooks/hook-costwatch/src/hook.ts +178 -0
  16. package/hooks/hook-desktopnotify/README.md +50 -0
  17. package/hooks/hook-desktopnotify/package.json +57 -0
  18. package/hooks/hook-desktopnotify/src/hook.ts +112 -0
  19. package/hooks/hook-envsetup/README.md +40 -0
  20. package/hooks/hook-envsetup/package.json +58 -0
  21. package/hooks/hook-envsetup/src/hook.ts +197 -0
  22. package/hooks/hook-errornotify/README.md +66 -0
  23. package/hooks/hook-errornotify/package.json +12 -0
  24. package/hooks/hook-errornotify/src/hook.ts +197 -0
  25. package/hooks/hook-permissionguard/README.md +48 -0
  26. package/hooks/hook-permissionguard/package.json +58 -0
  27. package/hooks/hook-permissionguard/src/hook.ts +268 -0
  28. package/hooks/hook-promptguard/README.md +64 -0
  29. package/hooks/hook-promptguard/package.json +12 -0
  30. package/hooks/hook-promptguard/src/hook.ts +200 -0
  31. package/hooks/hook-protectfiles/README.md +62 -0
  32. package/hooks/hook-protectfiles/package.json +58 -0
  33. package/hooks/hook-protectfiles/src/hook.ts +267 -0
  34. package/hooks/hook-sessionlog/README.md +48 -0
  35. package/hooks/hook-sessionlog/package.json +12 -0
  36. package/hooks/hook-sessionlog/src/hook.ts +100 -0
  37. package/hooks/hook-slacknotify/README.md +62 -0
  38. package/hooks/hook-slacknotify/package.json +12 -0
  39. package/hooks/hook-slacknotify/src/hook.ts +146 -0
  40. package/hooks/hook-soundnotify/README.md +63 -0
  41. package/hooks/hook-soundnotify/package.json +12 -0
  42. package/hooks/hook-soundnotify/src/hook.ts +173 -0
  43. package/hooks/hook-taskgate/README.md +62 -0
  44. package/hooks/hook-taskgate/package.json +12 -0
  45. package/hooks/hook-taskgate/src/hook.ts +169 -0
  46. package/hooks/hook-tddguard/README.md +50 -0
  47. package/hooks/hook-tddguard/package.json +12 -0
  48. package/hooks/hook-tddguard/src/hook.ts +263 -0
  49. 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.6",
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/open-hooks.git"
61
+ "url": "git+https://github.com/hasna/hooks.git"
61
62
  }
62
63
  }