@hasna/hooks 0.0.7 → 0.1.1

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 (48) hide show
  1. package/bin/index.js +240 -42
  2. package/dist/index.js +228 -30
  3. package/hooks/hook-autoformat/README.md +39 -0
  4. package/hooks/hook-autoformat/package.json +58 -0
  5. package/hooks/hook-autoformat/src/hook.ts +223 -0
  6. package/hooks/hook-autostage/README.md +70 -0
  7. package/hooks/hook-autostage/package.json +12 -0
  8. package/hooks/hook-autostage/src/hook.ts +167 -0
  9. package/hooks/hook-commandlog/README.md +45 -0
  10. package/hooks/hook-commandlog/package.json +12 -0
  11. package/hooks/hook-commandlog/src/hook.ts +92 -0
  12. package/hooks/hook-costwatch/README.md +61 -0
  13. package/hooks/hook-costwatch/package.json +12 -0
  14. package/hooks/hook-costwatch/src/hook.ts +178 -0
  15. package/hooks/hook-desktopnotify/README.md +50 -0
  16. package/hooks/hook-desktopnotify/package.json +57 -0
  17. package/hooks/hook-desktopnotify/src/hook.ts +112 -0
  18. package/hooks/hook-envsetup/README.md +40 -0
  19. package/hooks/hook-envsetup/package.json +58 -0
  20. package/hooks/hook-envsetup/src/hook.ts +197 -0
  21. package/hooks/hook-errornotify/README.md +66 -0
  22. package/hooks/hook-errornotify/package.json +12 -0
  23. package/hooks/hook-errornotify/src/hook.ts +197 -0
  24. package/hooks/hook-permissionguard/README.md +48 -0
  25. package/hooks/hook-permissionguard/package.json +58 -0
  26. package/hooks/hook-permissionguard/src/hook.ts +268 -0
  27. package/hooks/hook-promptguard/README.md +64 -0
  28. package/hooks/hook-promptguard/package.json +12 -0
  29. package/hooks/hook-promptguard/src/hook.ts +200 -0
  30. package/hooks/hook-protectfiles/README.md +62 -0
  31. package/hooks/hook-protectfiles/package.json +58 -0
  32. package/hooks/hook-protectfiles/src/hook.ts +267 -0
  33. package/hooks/hook-sessionlog/README.md +48 -0
  34. package/hooks/hook-sessionlog/package.json +12 -0
  35. package/hooks/hook-sessionlog/src/hook.ts +100 -0
  36. package/hooks/hook-slacknotify/README.md +62 -0
  37. package/hooks/hook-slacknotify/package.json +12 -0
  38. package/hooks/hook-slacknotify/src/hook.ts +146 -0
  39. package/hooks/hook-soundnotify/README.md +63 -0
  40. package/hooks/hook-soundnotify/package.json +12 -0
  41. package/hooks/hook-soundnotify/src/hook.ts +173 -0
  42. package/hooks/hook-taskgate/README.md +62 -0
  43. package/hooks/hook-taskgate/package.json +12 -0
  44. package/hooks/hook-taskgate/src/hook.ts +169 -0
  45. package/hooks/hook-tddguard/README.md +50 -0
  46. package/hooks/hook-tddguard/package.json +12 -0
  47. package/hooks/hook-tddguard/src/hook.ts +263 -0
  48. package/package.json +3 -3
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: taskgate
5
+ *
6
+ * TaskCompleted hook that validates a task is actually complete before
7
+ * allowing it to be marked done. Lightweight gate designed to be
8
+ * extended by users with custom validation logic.
9
+ *
10
+ * Current checks:
11
+ * - If task mentions "test" or "tests", verifies test files exist in cwd
12
+ * - If task mentions "lint" or "format", approves (can't verify externally)
13
+ * - For all other tasks, approves by default
14
+ */
15
+
16
+ import { readFileSync, existsSync, readdirSync } from "fs";
17
+ import { join } from "path";
18
+
19
+ interface HookInput {
20
+ session_id: string;
21
+ cwd: string;
22
+ tool_name: string;
23
+ tool_input: Record<string, unknown>;
24
+ }
25
+
26
+ interface HookOutput {
27
+ decision: "approve" | "block";
28
+ reason?: string;
29
+ }
30
+
31
+ /**
32
+ * Read and parse JSON from stdin
33
+ */
34
+ function readStdinJson(): HookInput | null {
35
+ try {
36
+ const input = readFileSync(0, "utf-8").trim();
37
+ if (!input) return null;
38
+ return JSON.parse(input);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Recursively check if any test files exist in a directory
46
+ * Looks for common test file patterns: *.test.*, *.spec.*, test_*, *_test.*
47
+ */
48
+ function hasTestFiles(dir: string, depth: number = 0): boolean {
49
+ if (depth > 4) return false; // Don't recurse too deep
50
+
51
+ try {
52
+ const entries = readdirSync(dir, { withFileTypes: true });
53
+
54
+ for (const entry of entries) {
55
+ const name = entry.name;
56
+
57
+ // Skip node_modules, .git, dist, build, etc.
58
+ if (entry.isDirectory()) {
59
+ if (["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"].includes(name)) {
60
+ continue;
61
+ }
62
+
63
+ // Check common test directories
64
+ if (["test", "tests", "__tests__", "spec", "specs"].includes(name)) {
65
+ return true;
66
+ }
67
+
68
+ // Recurse into subdirectories
69
+ if (hasTestFiles(join(dir, name), depth + 1)) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ // Check test file patterns
75
+ if (entry.isFile()) {
76
+ const lower = name.toLowerCase();
77
+ if (
78
+ lower.includes(".test.") ||
79
+ lower.includes(".spec.") ||
80
+ lower.startsWith("test_") ||
81
+ lower.endsWith("_test.py") ||
82
+ lower.endsWith("_test.go") ||
83
+ lower.endsWith("_test.ts") ||
84
+ lower.endsWith("_test.js")
85
+ ) {
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ } catch {
91
+ // Directory read failed — can't verify, so don't block
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Extract task description from tool_input
100
+ */
101
+ function getTaskDescription(toolInput: Record<string, unknown>): string {
102
+ // Try common field names for task description
103
+ const candidates = [
104
+ toolInput.description,
105
+ toolInput.task,
106
+ toolInput.title,
107
+ toolInput.summary,
108
+ toolInput.content,
109
+ toolInput.text,
110
+ ];
111
+
112
+ for (const candidate of candidates) {
113
+ if (candidate && typeof candidate === "string") {
114
+ return candidate;
115
+ }
116
+ }
117
+
118
+ // Fallback: stringify the whole input
119
+ return JSON.stringify(toolInput).toLowerCase();
120
+ }
121
+
122
+ /**
123
+ * Output hook response
124
+ */
125
+ function respond(output: HookOutput): void {
126
+ console.log(JSON.stringify(output));
127
+ }
128
+
129
+ /**
130
+ * Main hook execution
131
+ */
132
+ export function run(): void {
133
+ const input = readStdinJson();
134
+
135
+ if (!input) {
136
+ respond({ decision: "approve" });
137
+ return;
138
+ }
139
+
140
+ const description = getTaskDescription(input.tool_input || {}).toLowerCase();
141
+ const cwd = input.cwd;
142
+
143
+ // Check: if the task mentions tests, verify test files exist
144
+ if (/\btests?\b/.test(description)) {
145
+ if (!hasTestFiles(cwd)) {
146
+ console.error("[hook-taskgate] Task mentions tests but no test files found in project");
147
+ respond({
148
+ decision: "block",
149
+ reason: "Task mentions tests but no test files were found in the project. Please create test files before marking this task as complete.",
150
+ });
151
+ return;
152
+ }
153
+ console.error("[hook-taskgate] Task mentions tests — test files found, approved");
154
+ }
155
+
156
+ // Check: if the task mentions lint/format, approve (can't verify externally)
157
+ if (/\b(lint|linting|format|formatting)\b/.test(description)) {
158
+ console.error("[hook-taskgate] Task mentions lint/format — approved (cannot verify externally)");
159
+ respond({ decision: "approve" });
160
+ return;
161
+ }
162
+
163
+ // Default: approve all other tasks
164
+ respond({ decision: "approve" });
165
+ }
166
+
167
+ if (import.meta.main) {
168
+ run();
169
+ }
@@ -0,0 +1,50 @@
1
+ # hook-tddguard
2
+
3
+ Claude Code hook that enforces Test-Driven Development by blocking implementation file edits unless a corresponding test file exists.
4
+
5
+ ## Overview
6
+
7
+ Before allowing edits to implementation files, this hook checks whether a corresponding test file exists. If no test file is found, the edit is blocked with a message to write tests first.
8
+
9
+ ## Event
10
+
11
+ - **PreToolUse** (matcher: `Edit|Write`)
12
+
13
+ ## Behavior
14
+
15
+ - **Test files** (`*.test.ts`, `*.spec.ts`, `*_test.py`, `test_*.py`, `*_test.go`) are always approved
16
+ - **Config files** (`*.json`, `*.md`, `*.yml`, `*.yaml`, `*.toml`, `*.css`, `*.html`) are always approved
17
+ - **Implementation files** are checked for a corresponding test file:
18
+ - Same directory: `foo.test.ts`, `foo.spec.ts`
19
+ - `__tests__/` subdirectory
20
+ - `tests/` subdirectory
21
+ - Python: `test_foo.py`, `foo_test.py`
22
+ - Go: `foo_test.go`
23
+ - If no test file exists, the edit is **blocked**
24
+
25
+ ## Supported Languages
26
+
27
+ | Language | Test File Patterns |
28
+ |----------|--------------------|
29
+ | TypeScript/JavaScript | `*.test.ts`, `*.spec.ts`, `*.test.js`, `*.spec.js` |
30
+ | Python | `test_*.py`, `*_test.py` |
31
+ | Go | `*_test.go` |
32
+ | Java | `*Test.java` |
33
+ | Ruby | `*_test.rb`, `*_spec.rb` |
34
+
35
+ ## Example
36
+
37
+ ```
38
+ # Editing src/utils.ts without src/utils.test.ts existing:
39
+ # → BLOCKED: "Write tests first (TDD). No test file found for utils.ts."
40
+
41
+ # Editing src/utils.test.ts:
42
+ # → APPROVED (always)
43
+
44
+ # Editing src/utils.ts WITH src/utils.test.ts existing:
45
+ # → APPROVED
46
+ ```
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "hook-tddguard",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code hook that enforces TDD by blocking implementation edits without corresponding test files",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "MIT"
12
+ }
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.0.7",
4
- "description": "Open source Claude Code hooks library - Install hooks with a single command",
3
+ "version": "0.1.1",
4
+ "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hooks": "bin/index.js"
@@ -58,6 +58,6 @@
58
58
  },
59
59
  "repository": {
60
60
  "type": "git",
61
- "url": "git+https://github.com/hasna/open-hooks.git"
61
+ "url": "git+https://github.com/hasna/hooks.git"
62
62
  }
63
63
  }