@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,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
+ }