@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,112 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: desktopnotify
|
|
5
|
+
*
|
|
6
|
+
* Stop hook that sends a native desktop notification when Claude stops.
|
|
7
|
+
*
|
|
8
|
+
* Platform support:
|
|
9
|
+
* - macOS: osascript (display notification)
|
|
10
|
+
* - Linux: notify-send
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
import { basename } from "path";
|
|
16
|
+
|
|
17
|
+
interface HookInput {
|
|
18
|
+
session_id: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
hook_event_name?: string;
|
|
21
|
+
stop_hook_active?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface HookOutput {
|
|
25
|
+
continue: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readStdinJson(): HookInput | null {
|
|
29
|
+
try {
|
|
30
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
31
|
+
if (!input) return null;
|
|
32
|
+
return JSON.parse(input);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function respond(output: HookOutput): void {
|
|
39
|
+
console.log(JSON.stringify(output));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeForShell(str: string): string {
|
|
43
|
+
return str.replace(/'/g, "'\\''");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendMacNotification(title: string, message: string): void {
|
|
47
|
+
const escapedTitle = escapeForShell(title);
|
|
48
|
+
const escapedMessage = escapeForShell(message);
|
|
49
|
+
const script = `display notification "${escapedMessage}" with title "${escapedTitle}" sound name "Glass"`;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
execSync(`osascript -e '${script}'`, {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
55
|
+
timeout: 5000,
|
|
56
|
+
});
|
|
57
|
+
console.error("[hook-desktopnotify] macOS notification sent");
|
|
58
|
+
} catch (error: unknown) {
|
|
59
|
+
const execError = error as { message?: string };
|
|
60
|
+
console.error(`[hook-desktopnotify] macOS notification failed: ${execError.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sendLinuxNotification(title: string, message: string): void {
|
|
65
|
+
const escapedTitle = escapeForShell(title);
|
|
66
|
+
const escapedMessage = escapeForShell(message);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
execSync(`notify-send '${escapedTitle}' '${escapedMessage}' --urgency=normal --expire-time=5000`, {
|
|
70
|
+
encoding: "utf-8",
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
});
|
|
74
|
+
console.error("[hook-desktopnotify] Linux notification sent");
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
const execError = error as { message?: string };
|
|
77
|
+
console.error(`[hook-desktopnotify] Linux notification failed: ${execError.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sendNotification(title: string, message: string): void {
|
|
82
|
+
const platform = process.platform;
|
|
83
|
+
|
|
84
|
+
if (platform === "darwin") {
|
|
85
|
+
sendMacNotification(title, message);
|
|
86
|
+
} else if (platform === "linux") {
|
|
87
|
+
sendLinuxNotification(title, message);
|
|
88
|
+
} else {
|
|
89
|
+
console.error(`[hook-desktopnotify] Unsupported platform: ${platform}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function run(): void {
|
|
94
|
+
const input = readStdinJson();
|
|
95
|
+
|
|
96
|
+
if (!input) {
|
|
97
|
+
respond({ continue: true });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const projectName = input.cwd ? basename(input.cwd) : "unknown";
|
|
102
|
+
const title = "Claude Code — Done";
|
|
103
|
+
const message = `Claude has finished working on ${projectName} and is waiting for your input.`;
|
|
104
|
+
|
|
105
|
+
sendNotification(title, message);
|
|
106
|
+
|
|
107
|
+
respond({ continue: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (import.meta.main) {
|
|
111
|
+
run();
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# hook-envsetup
|
|
2
|
+
|
|
3
|
+
Claude Code hook that warns when environment activation may be needed before running commands.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Detects when a project uses version managers (nvm, pyenv, asdf, rbenv) or virtual environments, and warns via stderr if the command being run might need them activated first. This is advisory only — it never blocks commands.
|
|
8
|
+
|
|
9
|
+
## Hook Event
|
|
10
|
+
|
|
11
|
+
- **PreToolUse** (matcher: `Bash`)
|
|
12
|
+
|
|
13
|
+
## Detected Environments
|
|
14
|
+
|
|
15
|
+
| Config File | Environment | Commands Watched |
|
|
16
|
+
|-------------|-------------|------------------|
|
|
17
|
+
| `.nvmrc`, `.node-version` | nvm | `node`, `npm`, `npx`, `yarn`, `pnpm` |
|
|
18
|
+
| `.python-version`, `Pipfile`, `requirements.txt` | Python venv | `python`, `pip`, `pipenv` |
|
|
19
|
+
| `poetry.lock` | Poetry | `python`, `pip`, `poetry` |
|
|
20
|
+
| `.tool-versions` | asdf | `node`, `python`, `ruby`, `go`, etc. |
|
|
21
|
+
| `.ruby-version` | rbenv | `ruby`, `gem`, `bundle`, `rails` |
|
|
22
|
+
|
|
23
|
+
## Behavior
|
|
24
|
+
|
|
25
|
+
1. Fires before every Bash command
|
|
26
|
+
2. Checks if environment config files exist in the working directory
|
|
27
|
+
3. Checks if the command involves tools that need the environment
|
|
28
|
+
4. If env file exists but activation is not in the command, logs a warning to stderr
|
|
29
|
+
5. Always outputs `{ decision: "approve" }` — never blocks
|
|
30
|
+
|
|
31
|
+
## Smart Detection
|
|
32
|
+
|
|
33
|
+
The hook skips warnings when:
|
|
34
|
+
- The command already includes the activation step (`nvm use`, `source .venv/bin/activate`, etc.)
|
|
35
|
+
- The relevant environment variable is already set (`VIRTUAL_ENV`, `NVM_DIR`)
|
|
36
|
+
- The `.venv` directory doesn't exist yet (suggests creating one instead)
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna/hook-envsetup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that warns when environment activation may be needed",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hook-envsetup": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/hook.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/hook.js",
|
|
13
|
+
"types": "./dist/hook.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./cli": {
|
|
16
|
+
"import": "./dist/cli.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "bun build ./src/hook.ts --outdir ./dist --target node",
|
|
25
|
+
"prepublishOnly": "bun run build",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude-code",
|
|
30
|
+
"claude",
|
|
31
|
+
"hook",
|
|
32
|
+
"environment",
|
|
33
|
+
"nvm",
|
|
34
|
+
"python",
|
|
35
|
+
"venv",
|
|
36
|
+
"asdf",
|
|
37
|
+
"cli"
|
|
38
|
+
],
|
|
39
|
+
"author": "Hasna",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/hasna/open-hooks.git"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18",
|
|
51
|
+
"bun": ">=1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "^1.3.8",
|
|
55
|
+
"@types/node": "^20",
|
|
56
|
+
"typescript": "^5.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: envsetup
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse hook that checks if the environment might need activation
|
|
7
|
+
* before running commands. Since PreToolUse cannot modify commands,
|
|
8
|
+
* this hook logs warnings to stderr when it detects that:
|
|
9
|
+
*
|
|
10
|
+
* - .nvmrc or .node-version exists and command uses node/npm
|
|
11
|
+
* - .python-version or Pipfile exists and command uses python/pip
|
|
12
|
+
* - .tool-versions (asdf) exists
|
|
13
|
+
* - .venv directory exists but command doesn't activate it
|
|
14
|
+
*
|
|
15
|
+
* Always approves — this is advisory only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
|
|
21
|
+
interface HookInput {
|
|
22
|
+
session_id: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
tool_name: string;
|
|
25
|
+
tool_input: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HookOutput {
|
|
29
|
+
decision: "approve";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface EnvCheck {
|
|
33
|
+
name: string;
|
|
34
|
+
files: string[];
|
|
35
|
+
commandPatterns: RegExp[];
|
|
36
|
+
warning: string;
|
|
37
|
+
activationHint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readStdinJson(): HookInput | null {
|
|
41
|
+
try {
|
|
42
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
43
|
+
if (!input) return null;
|
|
44
|
+
return JSON.parse(input);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function respond(output: HookOutput): void {
|
|
51
|
+
console.log(JSON.stringify(output));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ENV_CHECKS: EnvCheck[] = [
|
|
55
|
+
{
|
|
56
|
+
name: "Node.js (nvm)",
|
|
57
|
+
files: [".nvmrc", ".node-version"],
|
|
58
|
+
commandPatterns: [
|
|
59
|
+
/\bnode\s/,
|
|
60
|
+
/\bnpm\s/,
|
|
61
|
+
/\bnpx\s/,
|
|
62
|
+
/\byarn\s/,
|
|
63
|
+
/\bpnpm\s/,
|
|
64
|
+
],
|
|
65
|
+
warning: "Project has .nvmrc/.node-version but nvm may not be activated",
|
|
66
|
+
activationHint: "source ~/.nvm/nvm.sh && nvm use",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "Python (venv)",
|
|
70
|
+
files: [".python-version", "Pipfile", "requirements.txt"],
|
|
71
|
+
commandPatterns: [
|
|
72
|
+
/\bpython\b/,
|
|
73
|
+
/\bpython3\b/,
|
|
74
|
+
/\bpip\b/,
|
|
75
|
+
/\bpip3\b/,
|
|
76
|
+
/\bpipenv\b/,
|
|
77
|
+
],
|
|
78
|
+
warning: "Project has Python config but virtualenv may not be activated",
|
|
79
|
+
activationHint: "source .venv/bin/activate",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "asdf",
|
|
83
|
+
files: [".tool-versions"],
|
|
84
|
+
commandPatterns: [
|
|
85
|
+
/\bnode\s/,
|
|
86
|
+
/\bnpm\s/,
|
|
87
|
+
/\bpython\b/,
|
|
88
|
+
/\bruby\b/,
|
|
89
|
+
/\belixir\b/,
|
|
90
|
+
/\berlang\b/,
|
|
91
|
+
/\bjava\b/,
|
|
92
|
+
/\bgo\s/,
|
|
93
|
+
],
|
|
94
|
+
warning: "Project has .tool-versions but asdf may not be sourced",
|
|
95
|
+
activationHint: "source ~/.asdf/asdf.sh",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "Python (Poetry)",
|
|
99
|
+
files: ["poetry.lock"],
|
|
100
|
+
commandPatterns: [
|
|
101
|
+
/\bpython\b/,
|
|
102
|
+
/\bpython3\b/,
|
|
103
|
+
/\bpip\b/,
|
|
104
|
+
/\bpoetry\b/,
|
|
105
|
+
],
|
|
106
|
+
warning: "Project uses Poetry but virtualenv may not be activated",
|
|
107
|
+
activationHint: "poetry shell",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "Ruby (rbenv)",
|
|
111
|
+
files: [".ruby-version"],
|
|
112
|
+
commandPatterns: [
|
|
113
|
+
/\bruby\b/,
|
|
114
|
+
/\bgem\b/,
|
|
115
|
+
/\bbundle\b/,
|
|
116
|
+
/\brails\b/,
|
|
117
|
+
/\brake\b/,
|
|
118
|
+
],
|
|
119
|
+
warning: "Project has .ruby-version but rbenv may not be initialized",
|
|
120
|
+
activationHint: "eval \"$(rbenv init -)\"",
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
function checkEnvironment(cwd: string, command: string): void {
|
|
125
|
+
for (const check of ENV_CHECKS) {
|
|
126
|
+
// Check if any of the config files exist
|
|
127
|
+
const hasConfigFile = check.files.some((file) => existsSync(join(cwd, file)));
|
|
128
|
+
if (!hasConfigFile) continue;
|
|
129
|
+
|
|
130
|
+
// Check if the command matches any of the patterns
|
|
131
|
+
const matchesCommand = check.commandPatterns.some((pattern) => pattern.test(command));
|
|
132
|
+
if (!matchesCommand) continue;
|
|
133
|
+
|
|
134
|
+
// Check if the command already includes the activation
|
|
135
|
+
if (command.includes("nvm use") || command.includes("nvm.sh")) continue;
|
|
136
|
+
if (command.includes(".venv/bin/activate") || command.includes("venv/bin/activate")) continue;
|
|
137
|
+
if (command.includes("asdf.sh")) continue;
|
|
138
|
+
if (command.includes("poetry shell") || command.includes("poetry run")) continue;
|
|
139
|
+
if (command.includes("rbenv init")) continue;
|
|
140
|
+
|
|
141
|
+
// For Python: check if .venv exists (if not, no point warning)
|
|
142
|
+
if (check.name === "Python (venv)") {
|
|
143
|
+
const hasVenv = existsSync(join(cwd, ".venv")) || existsSync(join(cwd, "venv"));
|
|
144
|
+
if (!hasVenv) {
|
|
145
|
+
// Check if VIRTUAL_ENV is set
|
|
146
|
+
if (!process.env.VIRTUAL_ENV) {
|
|
147
|
+
console.error(`[hook-envsetup] Warning: ${check.warning} (no .venv directory found, consider creating one)`);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Check if already in a venv
|
|
152
|
+
if (process.env.VIRTUAL_ENV) continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// For nvm: check if NVM_DIR is set
|
|
156
|
+
if (check.name === "Node.js (nvm)") {
|
|
157
|
+
if (!process.env.NVM_DIR) {
|
|
158
|
+
console.error(`[hook-envsetup] Warning: ${check.warning} (NVM_DIR not set)`);
|
|
159
|
+
console.error(`[hook-envsetup] Hint: ${check.activationHint}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.error(`[hook-envsetup] Warning: ${check.warning}`);
|
|
165
|
+
console.error(`[hook-envsetup] Hint: ${check.activationHint}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function run(): void {
|
|
170
|
+
const input = readStdinJson();
|
|
171
|
+
|
|
172
|
+
if (!input) {
|
|
173
|
+
respond({ decision: "approve" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (input.tool_name !== "Bash") {
|
|
178
|
+
respond({ decision: "approve" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const command = input.tool_input?.command as string;
|
|
183
|
+
if (!command || typeof command !== "string") {
|
|
184
|
+
respond({ decision: "approve" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const cwd = input.cwd || process.cwd();
|
|
189
|
+
checkEnvironment(cwd, command);
|
|
190
|
+
|
|
191
|
+
// Always approve — this hook is advisory only
|
|
192
|
+
respond({ decision: "approve" });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (import.meta.main) {
|
|
196
|
+
run();
|
|
197
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# hook-errornotify
|
|
2
|
+
|
|
3
|
+
Claude Code hook that detects tool failures and logs errors for tracking.
|
|
4
|
+
|
|
5
|
+
## Event
|
|
6
|
+
|
|
7
|
+
**PostToolUse** — fires after any tool execution completes.
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
Inspects tool output for error indicators and provides two layers of notification:
|
|
12
|
+
|
|
13
|
+
1. **stderr warnings** — immediate visibility in the terminal
|
|
14
|
+
2. **`.claude/errors.log`** — persistent error log file for later review
|
|
15
|
+
|
|
16
|
+
### Error Detection
|
|
17
|
+
|
|
18
|
+
The hook checks for:
|
|
19
|
+
|
|
20
|
+
- Non-zero exit codes (`exit_code`, `exitCode`, `code` fields)
|
|
21
|
+
- Explicit `error` field in output
|
|
22
|
+
- Error patterns in output text:
|
|
23
|
+
- `error:`, `fatal:`, `panic:`
|
|
24
|
+
- `command not found`, `permission denied`, `no such file or directory`
|
|
25
|
+
- `ENOENT`, `EACCES`, `EPERM`, `ENOMEM`
|
|
26
|
+
- Python/JS/Go exceptions (`TypeError`, `ImportError`, `FileNotFoundError`, etc.)
|
|
27
|
+
- Python tracebacks
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Add to your `.claude/settings.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"hooks": {
|
|
36
|
+
"PostToolUse": [
|
|
37
|
+
{
|
|
38
|
+
"matcher": "",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "bun run hooks/hook-errornotify/src/hook.ts"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Output
|
|
52
|
+
|
|
53
|
+
Always `{ "continue": true }` — this hook never blocks. It only observes and logs.
|
|
54
|
+
|
|
55
|
+
## Error Log Format
|
|
56
|
+
|
|
57
|
+
Errors are written to `.claude/errors.log`:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[2026-02-14T10:30:00.000Z] [session:abc12345] Bash: npm test — Exit code 1: Tests failed
|
|
61
|
+
[2026-02-14T10:31:00.000Z] [session:abc12345] Write: src/index.ts — Error: EACCES permission denied
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-errornotify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that detects tool failures and logs errors",
|
|
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,197 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: errornotify
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that detects tool failures and logs errors.
|
|
7
|
+
* Checks tool output for error indicators (non-zero exit codes,
|
|
8
|
+
* error messages) and logs warnings to stderr. Optionally writes
|
|
9
|
+
* to a .claude/errors.log file for persistent error tracking.
|
|
10
|
+
*
|
|
11
|
+
* Never blocks — always outputs { continue: true }.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, appendFileSync, mkdirSync, existsSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
|
|
17
|
+
interface HookInput {
|
|
18
|
+
session_id: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
tool_name: string;
|
|
21
|
+
tool_input: Record<string, unknown>;
|
|
22
|
+
tool_output?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HookOutput {
|
|
26
|
+
continue: true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read and parse JSON from stdin
|
|
31
|
+
*/
|
|
32
|
+
function readStdinJson(): HookInput | null {
|
|
33
|
+
try {
|
|
34
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
35
|
+
if (!input) return null;
|
|
36
|
+
return JSON.parse(input);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if the tool output indicates a failure
|
|
44
|
+
*/
|
|
45
|
+
function detectError(input: HookInput): { isError: boolean; message: string } {
|
|
46
|
+
const output = input.tool_output || {};
|
|
47
|
+
|
|
48
|
+
// Check for explicit exit code
|
|
49
|
+
const exitCode = output.exit_code ?? output.exitCode ?? output.code;
|
|
50
|
+
if (exitCode !== undefined && exitCode !== null && exitCode !== 0) {
|
|
51
|
+
const stderr = (output.stderr as string) || (output.output as string) || "unknown error";
|
|
52
|
+
return {
|
|
53
|
+
isError: true,
|
|
54
|
+
message: `Exit code ${exitCode}: ${truncate(stderr, 200)}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for error field
|
|
59
|
+
if (output.error && typeof output.error === "string") {
|
|
60
|
+
return {
|
|
61
|
+
isError: true,
|
|
62
|
+
message: `Error: ${truncate(output.error, 200)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check output text for common error indicators
|
|
67
|
+
const outputText =
|
|
68
|
+
(output.stderr as string) ||
|
|
69
|
+
(output.output as string) ||
|
|
70
|
+
(output.content as string) ||
|
|
71
|
+
(output.text as string) ||
|
|
72
|
+
"";
|
|
73
|
+
|
|
74
|
+
if (typeof outputText === "string" && outputText.length > 0) {
|
|
75
|
+
// Check for common error patterns in output
|
|
76
|
+
const errorPatterns = [
|
|
77
|
+
/^error:/im,
|
|
78
|
+
/^fatal:/im,
|
|
79
|
+
/^panic:/im,
|
|
80
|
+
/command not found/i,
|
|
81
|
+
/permission denied/i,
|
|
82
|
+
/no such file or directory/i,
|
|
83
|
+
/segmentation fault/i,
|
|
84
|
+
/killed/i,
|
|
85
|
+
/ENOENT/,
|
|
86
|
+
/EACCES/,
|
|
87
|
+
/EPERM/,
|
|
88
|
+
/ENOMEM/,
|
|
89
|
+
/TypeError:/,
|
|
90
|
+
/ReferenceError:/,
|
|
91
|
+
/SyntaxError:/,
|
|
92
|
+
/ModuleNotFoundError:/,
|
|
93
|
+
/ImportError:/,
|
|
94
|
+
/FileNotFoundError:/,
|
|
95
|
+
/PermissionError:/,
|
|
96
|
+
/traceback \(most recent call last\)/i,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const pattern of errorPatterns) {
|
|
100
|
+
if (pattern.test(outputText)) {
|
|
101
|
+
// Extract the first relevant line
|
|
102
|
+
const lines = outputText.split("\n").filter((l: string) => l.trim());
|
|
103
|
+
const errorLine = lines.find((l: string) => pattern.test(l)) || lines[0] || "";
|
|
104
|
+
return {
|
|
105
|
+
isError: true,
|
|
106
|
+
message: truncate(errorLine.trim(), 200),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { isError: false, message: "" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Truncate a string to a maximum length
|
|
117
|
+
*/
|
|
118
|
+
function truncate(str: string, maxLen: number): string {
|
|
119
|
+
if (str.length <= maxLen) return str;
|
|
120
|
+
return str.slice(0, maxLen) + "...";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get a human-readable description of what was being executed
|
|
125
|
+
*/
|
|
126
|
+
function getToolContext(input: HookInput): string {
|
|
127
|
+
const toolName = input.tool_name;
|
|
128
|
+
const toolInput = input.tool_input || {};
|
|
129
|
+
|
|
130
|
+
switch (toolName) {
|
|
131
|
+
case "Bash":
|
|
132
|
+
return `Bash: ${truncate((toolInput.command as string) || "unknown command", 100)}`;
|
|
133
|
+
case "Write":
|
|
134
|
+
case "Edit":
|
|
135
|
+
return `${toolName}: ${(toolInput.file_path as string) || "unknown file"}`;
|
|
136
|
+
case "Read":
|
|
137
|
+
return `Read: ${(toolInput.file_path as string) || "unknown file"}`;
|
|
138
|
+
default:
|
|
139
|
+
return toolName;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write error to .claude/errors.log
|
|
145
|
+
*/
|
|
146
|
+
function writeErrorLog(cwd: string, toolContext: string, errorMessage: string, sessionId: string): void {
|
|
147
|
+
try {
|
|
148
|
+
const claudeDir = join(cwd, ".claude");
|
|
149
|
+
if (!existsSync(claudeDir)) {
|
|
150
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const logFile = join(claudeDir, "errors.log");
|
|
154
|
+
const timestamp = new Date().toISOString();
|
|
155
|
+
const entry = `[${timestamp}] [session:${sessionId.slice(0, 8)}] ${toolContext} — ${errorMessage}\n`;
|
|
156
|
+
appendFileSync(logFile, entry);
|
|
157
|
+
} catch {
|
|
158
|
+
// Silently fail — logging should never cause issues
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Output hook response
|
|
164
|
+
*/
|
|
165
|
+
function respond(): void {
|
|
166
|
+
const output: HookOutput = { continue: true };
|
|
167
|
+
console.log(JSON.stringify(output));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Main hook execution
|
|
172
|
+
*/
|
|
173
|
+
export function run(): void {
|
|
174
|
+
const input = readStdinJson();
|
|
175
|
+
|
|
176
|
+
if (!input) {
|
|
177
|
+
respond();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const { isError, message } = detectError(input);
|
|
182
|
+
|
|
183
|
+
if (isError) {
|
|
184
|
+
const toolContext = getToolContext(input);
|
|
185
|
+
console.error(`[hook-errornotify] FAILURE in ${toolContext}`);
|
|
186
|
+
console.error(`[hook-errornotify] ${message}`);
|
|
187
|
+
|
|
188
|
+
// Write to persistent error log
|
|
189
|
+
writeErrorLog(input.cwd, toolContext, message, input.session_id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
respond();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (import.meta.main) {
|
|
196
|
+
run();
|
|
197
|
+
}
|