@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.
- package/bin/index.js +240 -42
- package/dist/index.js +228 -30
- 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 +3 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: sessionlog
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that logs every tool call to a session log file.
|
|
7
|
+
* Creates .claude/session-log-<date>.jsonl in the project directory.
|
|
8
|
+
*
|
|
9
|
+
* Each line is a JSON object with:
|
|
10
|
+
* - timestamp: ISO string
|
|
11
|
+
* - tool_name: name of the tool that was called
|
|
12
|
+
* - tool_input: first 500 characters of the stringified tool input
|
|
13
|
+
* - session_id: current session ID
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync, mkdirSync, appendFileSync } 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
|
+
continue: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readStdinJson(): HookInput | null {
|
|
31
|
+
try {
|
|
32
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
33
|
+
if (!input) return null;
|
|
34
|
+
return JSON.parse(input);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function respond(output: HookOutput): void {
|
|
41
|
+
console.log(JSON.stringify(output));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getDateString(): string {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
const year = now.getFullYear();
|
|
47
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
48
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
49
|
+
return `${year}-${month}-${day}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncate(str: string, maxLength: number): string {
|
|
53
|
+
if (str.length <= maxLength) return str;
|
|
54
|
+
return str.slice(0, maxLength) + "...";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function logToolCall(input: HookInput): void {
|
|
58
|
+
const claudeDir = join(input.cwd, ".claude");
|
|
59
|
+
|
|
60
|
+
// Create .claude/ directory if it doesn't exist
|
|
61
|
+
if (!existsSync(claudeDir)) {
|
|
62
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dateStr = getDateString();
|
|
66
|
+
const logFile = join(claudeDir, `session-log-${dateStr}.jsonl`);
|
|
67
|
+
|
|
68
|
+
const toolInputStr = truncate(JSON.stringify(input.tool_input), 500);
|
|
69
|
+
|
|
70
|
+
const logEntry = {
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
tool_name: input.tool_name,
|
|
73
|
+
tool_input: toolInputStr,
|
|
74
|
+
session_id: input.session_id,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
appendFileSync(logFile, JSON.stringify(logEntry) + "\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function run(): void {
|
|
81
|
+
const input = readStdinJson();
|
|
82
|
+
|
|
83
|
+
if (!input) {
|
|
84
|
+
respond({ continue: true });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
logToolCall(input);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
92
|
+
console.error(`[hook-sessionlog] Warning: failed to log tool call: ${errMsg}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
respond({ continue: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (import.meta.main) {
|
|
99
|
+
run();
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# hook-slacknotify
|
|
2
|
+
|
|
3
|
+
Claude Code hook that sends a Slack webhook notification when Claude Code finishes working.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When Claude stops, this hook sends a notification to a configured Slack channel via an incoming webhook URL. Useful for being notified when long-running tasks complete.
|
|
8
|
+
|
|
9
|
+
## Event
|
|
10
|
+
|
|
11
|
+
- **Stop** (no matcher)
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Configure the webhook URL via one of:
|
|
16
|
+
|
|
17
|
+
### 1. Environment Variable (preferred)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../xxx"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Settings File
|
|
24
|
+
|
|
25
|
+
Add to `~/.claude/settings.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"slackNotifyConfig": {
|
|
30
|
+
"webhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
31
|
+
"enabled": true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Slack Message Format
|
|
37
|
+
|
|
38
|
+
The hook sends a message with:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"text": "Claude Code finished in <project-name>",
|
|
43
|
+
"blocks": [{
|
|
44
|
+
"type": "section",
|
|
45
|
+
"text": {
|
|
46
|
+
"type": "mrkdwn",
|
|
47
|
+
"text": "*Claude Code* finished working in `<cwd>`"
|
|
48
|
+
}
|
|
49
|
+
}]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Behavior
|
|
54
|
+
|
|
55
|
+
- If no webhook URL is configured, logs a warning to stderr and continues
|
|
56
|
+
- If the webhook request fails, logs the error to stderr and continues
|
|
57
|
+
- Never blocks the session from ending
|
|
58
|
+
- Outputs `{ "continue": true }` always
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-slacknotify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that sends Slack webhook notifications when Claude stops",
|
|
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,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
|
+
}
|