@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,146 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: slacknotify
|
|
5
|
+
*
|
|
6
|
+
* Stop hook that sends a Slack webhook notification when Claude Code
|
|
7
|
+
* finishes working in a project.
|
|
8
|
+
*
|
|
9
|
+
* Configuration:
|
|
10
|
+
* - Environment variable: SLACK_WEBHOOK_URL
|
|
11
|
+
* - Or ~/.claude/settings.json key: slackNotifyConfig.webhookUrl
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
|
|
18
|
+
interface HookInput {
|
|
19
|
+
session_id: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
hook_event_name: string;
|
|
22
|
+
transcript_path?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HookOutput {
|
|
26
|
+
continue: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SlackNotifyConfig {
|
|
30
|
+
webhookUrl?: string;
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CONFIG_KEY = "slackNotifyConfig";
|
|
35
|
+
|
|
36
|
+
function readStdinJson(): HookInput | null {
|
|
37
|
+
try {
|
|
38
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
39
|
+
if (!input) return null;
|
|
40
|
+
return JSON.parse(input);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function respond(output: HookOutput): void {
|
|
47
|
+
console.log(JSON.stringify(output));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getConfig(): SlackNotifyConfig {
|
|
51
|
+
// Try global settings
|
|
52
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
53
|
+
try {
|
|
54
|
+
if (existsSync(settingsPath)) {
|
|
55
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
56
|
+
if (settings[CONFIG_KEY]) {
|
|
57
|
+
return settings[CONFIG_KEY] as SlackNotifyConfig;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Settings file unreadable, fall through
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getWebhookUrl(): string | null {
|
|
67
|
+
// Priority 1: environment variable
|
|
68
|
+
const envUrl = process.env.SLACK_WEBHOOK_URL;
|
|
69
|
+
if (envUrl) return envUrl;
|
|
70
|
+
|
|
71
|
+
// Priority 2: settings.json config
|
|
72
|
+
const config = getConfig();
|
|
73
|
+
if (config.webhookUrl) return config.webhookUrl;
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function sendSlackNotification(webhookUrl: string, cwd: string): Promise<void> {
|
|
79
|
+
const projectName = cwd.split("/").filter(Boolean).pop() || cwd;
|
|
80
|
+
|
|
81
|
+
const payload = {
|
|
82
|
+
text: `Claude Code finished in ${projectName}`,
|
|
83
|
+
blocks: [
|
|
84
|
+
{
|
|
85
|
+
type: "section",
|
|
86
|
+
text: {
|
|
87
|
+
type: "mrkdwn",
|
|
88
|
+
text: `*Claude Code* finished working in \`${cwd}\``,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(webhookUrl, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(payload),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[hook-slacknotify] Slack webhook returned ${response.status}: ${response.statusText}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
110
|
+
console.error(`[hook-slacknotify] Failed to send Slack notification: ${errMsg}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function run(): Promise<void> {
|
|
115
|
+
const input = readStdinJson();
|
|
116
|
+
|
|
117
|
+
if (!input) {
|
|
118
|
+
respond({ continue: true });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if hook is explicitly disabled
|
|
123
|
+
const config = getConfig();
|
|
124
|
+
if (config.enabled === false) {
|
|
125
|
+
respond({ continue: true });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const webhookUrl = getWebhookUrl();
|
|
130
|
+
|
|
131
|
+
if (!webhookUrl) {
|
|
132
|
+
console.error(
|
|
133
|
+
"[hook-slacknotify] No Slack webhook URL configured. " +
|
|
134
|
+
"Set SLACK_WEBHOOK_URL env var or add slackNotifyConfig.webhookUrl to ~/.claude/settings.json"
|
|
135
|
+
);
|
|
136
|
+
respond({ continue: true });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await sendSlackNotification(webhookUrl, input.cwd);
|
|
141
|
+
respond({ continue: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (import.meta.main) {
|
|
145
|
+
run();
|
|
146
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# hook-soundnotify
|
|
2
|
+
|
|
3
|
+
Claude Code hook that plays a sound when Claude finishes a session.
|
|
4
|
+
|
|
5
|
+
## Event
|
|
6
|
+
|
|
7
|
+
**Stop** — fires when Claude's session ends.
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
Plays a system sound to notify you that Claude has finished working. Useful when you tab away and want an audible alert.
|
|
12
|
+
|
|
13
|
+
### Platform Support
|
|
14
|
+
|
|
15
|
+
| Platform | Player | Default Sound |
|
|
16
|
+
|----------|--------|---------------|
|
|
17
|
+
| macOS | `afplay` | `/System/Library/Sounds/Glass.aiff` |
|
|
18
|
+
| Linux | `paplay` / `aplay` | `/usr/share/sounds/freedesktop/stereo/complete.oga` |
|
|
19
|
+
|
|
20
|
+
### Configuration
|
|
21
|
+
|
|
22
|
+
Set `HOOKS_SOUND_FILE` environment variable to use a custom sound:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export HOOKS_SOUND_FILE="/path/to/your/sound.wav"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Add to your `.claude/settings.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"hooks": {
|
|
35
|
+
"Stop": [
|
|
36
|
+
{
|
|
37
|
+
"matcher": "",
|
|
38
|
+
"hooks": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "bun run hooks/hook-soundnotify/src/hook.ts"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Output
|
|
51
|
+
|
|
52
|
+
Always `{ "continue": true }` — sound plays asynchronously (fire-and-forget) and never blocks session exit.
|
|
53
|
+
|
|
54
|
+
## How It Works
|
|
55
|
+
|
|
56
|
+
1. Looks for a sound file (env var > platform default)
|
|
57
|
+
2. Spawns the audio player as a detached child process
|
|
58
|
+
3. Immediately returns without waiting for playback to finish
|
|
59
|
+
4. The sound plays in the background after Claude exits
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-soundnotify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that plays a sound when Claude finishes",
|
|
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,173 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: soundnotify
|
|
5
|
+
*
|
|
6
|
+
* Stop hook that plays a system sound when Claude finishes a session.
|
|
7
|
+
*
|
|
8
|
+
* Platform support:
|
|
9
|
+
* - macOS: afplay (built-in)
|
|
10
|
+
* - Linux: paplay (PulseAudio) or aplay (ALSA) fallback
|
|
11
|
+
*
|
|
12
|
+
* Configuration:
|
|
13
|
+
* - HOOKS_SOUND_FILE env var: path to a custom sound file
|
|
14
|
+
*
|
|
15
|
+
* Runs async (fire-and-forget) — does not block session exit.
|
|
16
|
+
* Always outputs { continue: true }.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, existsSync } from "fs";
|
|
20
|
+
import { spawn } from "child_process";
|
|
21
|
+
import { platform } from "os";
|
|
22
|
+
|
|
23
|
+
interface HookInput {
|
|
24
|
+
session_id: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HookOutput {
|
|
29
|
+
continue: true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default sound files per platform
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_SOUNDS: Record<string, string> = {
|
|
36
|
+
darwin: "/System/Library/Sounds/Glass.aiff",
|
|
37
|
+
linux: "/usr/share/sounds/freedesktop/stereo/complete.oga",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fallback sound files for Linux
|
|
42
|
+
*/
|
|
43
|
+
const LINUX_FALLBACK_SOUNDS: string[] = [
|
|
44
|
+
"/usr/share/sounds/freedesktop/stereo/complete.oga",
|
|
45
|
+
"/usr/share/sounds/freedesktop/stereo/bell.oga",
|
|
46
|
+
"/usr/share/sounds/freedesktop/stereo/message.oga",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read and parse JSON from stdin
|
|
51
|
+
*/
|
|
52
|
+
function readStdinJson(): HookInput | null {
|
|
53
|
+
try {
|
|
54
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
55
|
+
if (!input) return null;
|
|
56
|
+
return JSON.parse(input);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find an available sound file
|
|
64
|
+
*/
|
|
65
|
+
function findSoundFile(): string | null {
|
|
66
|
+
// Check env var first
|
|
67
|
+
const envSound = process.env.HOOKS_SOUND_FILE;
|
|
68
|
+
if (envSound && existsSync(envSound)) {
|
|
69
|
+
return envSound;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const os = platform();
|
|
73
|
+
|
|
74
|
+
if (os === "darwin") {
|
|
75
|
+
const defaultSound = DEFAULT_SOUNDS.darwin;
|
|
76
|
+
if (existsSync(defaultSound)) {
|
|
77
|
+
return defaultSound;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (os === "linux") {
|
|
83
|
+
for (const sound of LINUX_FALLBACK_SOUNDS) {
|
|
84
|
+
if (existsSync(sound)) {
|
|
85
|
+
return sound;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the appropriate audio player command for the current platform
|
|
96
|
+
*/
|
|
97
|
+
function getPlayerCommand(soundFile: string): { cmd: string; args: string[] } | null {
|
|
98
|
+
const os = platform();
|
|
99
|
+
|
|
100
|
+
if (os === "darwin") {
|
|
101
|
+
return { cmd: "afplay", args: [soundFile] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (os === "linux") {
|
|
105
|
+
// Prefer paplay for PulseAudio, fallback to aplay for ALSA
|
|
106
|
+
if (soundFile.endsWith(".oga") || soundFile.endsWith(".ogg")) {
|
|
107
|
+
return { cmd: "paplay", args: [soundFile] };
|
|
108
|
+
}
|
|
109
|
+
return { cmd: "aplay", args: [soundFile] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Play the sound asynchronously (fire-and-forget)
|
|
117
|
+
*/
|
|
118
|
+
function playSound(soundFile: string): void {
|
|
119
|
+
const player = getPlayerCommand(soundFile);
|
|
120
|
+
if (!player) {
|
|
121
|
+
console.error(`[hook-soundnotify] No audio player available for ${platform()}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const child = spawn(player.cmd, player.args, {
|
|
127
|
+
stdio: "ignore",
|
|
128
|
+
detached: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Unref so the process doesn't keep the parent alive
|
|
132
|
+
child.unref();
|
|
133
|
+
|
|
134
|
+
console.error(`[hook-soundnotify] Playing: ${soundFile}`);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
137
|
+
console.error(`[hook-soundnotify] Failed to play sound: ${errMsg}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Output hook response
|
|
143
|
+
*/
|
|
144
|
+
function respond(): void {
|
|
145
|
+
const output: HookOutput = { continue: true };
|
|
146
|
+
console.log(JSON.stringify(output));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Main hook execution
|
|
151
|
+
*/
|
|
152
|
+
export function run(): void {
|
|
153
|
+
// Read stdin (we don't really need the input, but follow the protocol)
|
|
154
|
+
readStdinJson();
|
|
155
|
+
|
|
156
|
+
const soundFile = findSoundFile();
|
|
157
|
+
|
|
158
|
+
if (!soundFile) {
|
|
159
|
+
console.error("[hook-soundnotify] No sound file found, skipping");
|
|
160
|
+
respond();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fire-and-forget — play sound async
|
|
165
|
+
playSound(soundFile);
|
|
166
|
+
|
|
167
|
+
// Always continue
|
|
168
|
+
respond();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (import.meta.main) {
|
|
172
|
+
run();
|
|
173
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# hook-taskgate
|
|
2
|
+
|
|
3
|
+
Claude Code hook that validates task completion before allowing a task to be marked done.
|
|
4
|
+
|
|
5
|
+
## Event
|
|
6
|
+
|
|
7
|
+
**TaskCompleted** — fires when Claude attempts to mark a task as complete.
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
A lightweight gate that checks whether a task is actually done:
|
|
12
|
+
|
|
13
|
+
- **Task mentions "test" or "tests"** — verifies that test files exist in the project (looks for `*.test.*`, `*.spec.*`, `test_*`, `*_test.*`, or `test/`/`tests/`/`__tests__/` directories)
|
|
14
|
+
- **Task mentions "lint" or "format"** — approves (cannot verify externally)
|
|
15
|
+
- **All other tasks** — approves by default
|
|
16
|
+
|
|
17
|
+
This hook is designed as a starting point. Extend the validation logic in `src/hook.ts` for your own project needs.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add to your `.claude/settings.json`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"hooks": {
|
|
26
|
+
"TaskCompleted": [
|
|
27
|
+
{
|
|
28
|
+
"matcher": "",
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "bun run hooks/hook-taskgate/src/hook.ts"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Output
|
|
42
|
+
|
|
43
|
+
- `{ "decision": "approve" }` — task passes validation
|
|
44
|
+
- `{ "decision": "block", "reason": "..." }` — task fails validation, provides reason
|
|
45
|
+
|
|
46
|
+
## Extending
|
|
47
|
+
|
|
48
|
+
To add custom validation, edit `src/hook.ts` and add checks in the `run()` function before the default approve. For example:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// Block tasks mentioning "deploy" unless a deploy script exists
|
|
52
|
+
if (/\bdeploy\b/.test(description)) {
|
|
53
|
+
if (!existsSync(join(cwd, "deploy.sh"))) {
|
|
54
|
+
respond({ decision: "block", reason: "No deploy.sh found" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-taskgate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that validates task completion before marking done",
|
|
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,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
|
+
}
|