@hasna/hooks 0.0.7 → 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/bin/index.js +157 -2
- 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 +2 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# hook-autostage
|
|
2
|
+
|
|
3
|
+
Claude Code hook that automatically stages files after Claude edits or writes them.
|
|
4
|
+
|
|
5
|
+
## Event
|
|
6
|
+
|
|
7
|
+
**PostToolUse** (matcher: `Edit|Write`)
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
After Claude modifies a file via `Edit` or `Write`, this hook automatically runs `git add <file>` to stage the change. This keeps your git staging area in sync with Claude's edits without manual intervention.
|
|
12
|
+
|
|
13
|
+
### Safety Checks
|
|
14
|
+
|
|
15
|
+
Before staging, the hook verifies:
|
|
16
|
+
|
|
17
|
+
1. **Git repo exists** — checks that `cwd` is inside a git repository
|
|
18
|
+
2. **File exists** — confirms the file was actually created/modified
|
|
19
|
+
3. **Not gitignored** — runs `git check-ignore` to skip ignored files
|
|
20
|
+
|
|
21
|
+
### What Gets Staged
|
|
22
|
+
|
|
23
|
+
- Files modified by Claude's `Edit` tool
|
|
24
|
+
- Files created/written by Claude's `Write` tool
|
|
25
|
+
|
|
26
|
+
### What Does NOT Get Staged
|
|
27
|
+
|
|
28
|
+
- Files in `.gitignore` (e.g., `node_modules/`, `.env`, `dist/`)
|
|
29
|
+
- Files outside git repositories
|
|
30
|
+
- Files from other tools (Bash, Read, etc.)
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Add to your `.claude/settings.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"hooks": {
|
|
39
|
+
"PostToolUse": [
|
|
40
|
+
{
|
|
41
|
+
"matcher": "Edit|Write",
|
|
42
|
+
"hooks": [
|
|
43
|
+
{
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "bun run hooks/hook-autostage/src/hook.ts"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Output
|
|
55
|
+
|
|
56
|
+
Always `{ "continue": true }` — this hook never blocks.
|
|
57
|
+
|
|
58
|
+
## Logs
|
|
59
|
+
|
|
60
|
+
Activity is logged to stderr:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
[hook-autostage] Staged: src/index.ts
|
|
64
|
+
[hook-autostage] File is gitignored, skipping: dist/bundle.js
|
|
65
|
+
[hook-autostage] Not a git repo, skipping
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-autostage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that auto-stages files after Claude edits them",
|
|
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,167 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: autostage
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that automatically runs `git add <file>` after
|
|
7
|
+
* Claude edits or writes a file. Only stages if:
|
|
8
|
+
* - The project is a git repo (.git directory exists)
|
|
9
|
+
* - The file is not in .gitignore
|
|
10
|
+
*
|
|
11
|
+
* Matcher: Edit|Write
|
|
12
|
+
* Always outputs { continue: true } — never blocks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from "fs";
|
|
16
|
+
import { execSync } from "child_process";
|
|
17
|
+
import { join, resolve } 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: true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read and parse JSON from stdin
|
|
32
|
+
*/
|
|
33
|
+
function readStdinJson(): HookInput | null {
|
|
34
|
+
try {
|
|
35
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
36
|
+
if (!input) return null;
|
|
37
|
+
return JSON.parse(input);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a directory is inside a git repository
|
|
45
|
+
*/
|
|
46
|
+
function isGitRepo(cwd: string): boolean {
|
|
47
|
+
try {
|
|
48
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
49
|
+
cwd,
|
|
50
|
+
stdio: "pipe",
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a file is ignored by .gitignore
|
|
60
|
+
*/
|
|
61
|
+
function isGitIgnored(cwd: string, filePath: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
// git check-ignore exits 0 if file IS ignored, 1 if NOT ignored
|
|
64
|
+
execSync(`git check-ignore -q "${filePath}"`, {
|
|
65
|
+
cwd,
|
|
66
|
+
stdio: "pipe",
|
|
67
|
+
});
|
|
68
|
+
return true; // Exit 0 → file is ignored
|
|
69
|
+
} catch {
|
|
70
|
+
return false; // Exit 1 → file is NOT ignored
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stage a file with git add
|
|
76
|
+
*/
|
|
77
|
+
function stageFile(cwd: string, filePath: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
execSync(`git add "${filePath}"`, {
|
|
80
|
+
cwd,
|
|
81
|
+
stdio: "pipe",
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
86
|
+
console.error(`[hook-autostage] Failed to stage ${filePath}: ${errMsg}`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract file path from tool input
|
|
93
|
+
*/
|
|
94
|
+
function getFilePath(toolInput: Record<string, unknown>): string | null {
|
|
95
|
+
return (toolInput.file_path as string) || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Output hook response
|
|
100
|
+
*/
|
|
101
|
+
function respond(): void {
|
|
102
|
+
const output: HookOutput = { continue: true };
|
|
103
|
+
console.log(JSON.stringify(output));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Main hook execution
|
|
108
|
+
*/
|
|
109
|
+
export function run(): void {
|
|
110
|
+
const input = readStdinJson();
|
|
111
|
+
|
|
112
|
+
if (!input) {
|
|
113
|
+
respond();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only handle Edit and Write tools
|
|
118
|
+
if (input.tool_name !== "Edit" && input.tool_name !== "Write") {
|
|
119
|
+
respond();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const filePath = getFilePath(input.tool_input || {});
|
|
124
|
+
|
|
125
|
+
if (!filePath) {
|
|
126
|
+
console.error("[hook-autostage] No file_path found in tool_input");
|
|
127
|
+
respond();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cwd = input.cwd;
|
|
132
|
+
|
|
133
|
+
// Check if this is a git repo
|
|
134
|
+
if (!isGitRepo(cwd)) {
|
|
135
|
+
console.error("[hook-autostage] Not a git repo, skipping");
|
|
136
|
+
respond();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Resolve the file path relative to cwd
|
|
141
|
+
const absPath = resolve(cwd, filePath);
|
|
142
|
+
|
|
143
|
+
// Check if file exists
|
|
144
|
+
if (!existsSync(absPath)) {
|
|
145
|
+
console.error(`[hook-autostage] File does not exist: ${filePath}`);
|
|
146
|
+
respond();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if file is gitignored
|
|
151
|
+
if (isGitIgnored(cwd, filePath)) {
|
|
152
|
+
console.error(`[hook-autostage] File is gitignored, skipping: ${filePath}`);
|
|
153
|
+
respond();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Stage the file
|
|
158
|
+
if (stageFile(cwd, filePath)) {
|
|
159
|
+
console.error(`[hook-autostage] Staged: ${filePath}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
respond();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (import.meta.main) {
|
|
166
|
+
run();
|
|
167
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# hook-commandlog
|
|
2
|
+
|
|
3
|
+
Claude Code hook that logs every bash command Claude runs to a log file.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Every time Claude executes a Bash command, this hook appends it to `.claude/commands.log` in the project directory. Provides a clear audit trail of all shell commands run during a session.
|
|
8
|
+
|
|
9
|
+
## Event
|
|
10
|
+
|
|
11
|
+
- **PostToolUse** (matcher: `Bash`)
|
|
12
|
+
|
|
13
|
+
## Log Format
|
|
14
|
+
|
|
15
|
+
Each line in the log file:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
[2026-02-14T10:30:00.000Z] exit=0 npm install express
|
|
19
|
+
[2026-02-14T10:30:05.000Z] exit=0 git status
|
|
20
|
+
[2026-02-14T10:30:10.000Z] ls -la src/
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- ISO 8601 timestamp in brackets
|
|
24
|
+
- Exit code (if available in tool input)
|
|
25
|
+
- The full command string
|
|
26
|
+
|
|
27
|
+
## Behavior
|
|
28
|
+
|
|
29
|
+
- Only logs `Bash` tool calls (other tools are ignored)
|
|
30
|
+
- Creates `.claude/` directory if it does not exist
|
|
31
|
+
- Appends to the log file (never overwrites)
|
|
32
|
+
- Non-blocking: logging failures are logged to stderr but never interrupt Claude
|
|
33
|
+
- Outputs `{ "continue": true }` always
|
|
34
|
+
|
|
35
|
+
## Log Location
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
<project-root>/
|
|
39
|
+
└── .claude/
|
|
40
|
+
└── commands.log
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-commandlog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that logs every bash command Claude runs to a log file",
|
|
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,92 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: commandlog
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that logs every bash command Claude runs to
|
|
7
|
+
* .claude/commands.log in the project directory.
|
|
8
|
+
*
|
|
9
|
+
* Format: [ISO timestamp] <exit_code> <command>
|
|
10
|
+
* One command per line.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync, mkdirSync, appendFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
interface HookInput {
|
|
17
|
+
session_id: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
tool_name: string;
|
|
20
|
+
tool_input: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface HookOutput {
|
|
24
|
+
continue: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readStdinJson(): HookInput | null {
|
|
28
|
+
try {
|
|
29
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
30
|
+
if (!input) return null;
|
|
31
|
+
return JSON.parse(input);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function respond(output: HookOutput): void {
|
|
38
|
+
console.log(JSON.stringify(output));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function logCommand(input: HookInput): void {
|
|
42
|
+
const claudeDir = join(input.cwd, ".claude");
|
|
43
|
+
|
|
44
|
+
// Create .claude/ directory if it doesn't exist
|
|
45
|
+
if (!existsSync(claudeDir)) {
|
|
46
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const logFile = join(claudeDir, "commands.log");
|
|
50
|
+
const timestamp = new Date().toISOString();
|
|
51
|
+
const command = (input.tool_input.command as string) || "(unknown command)";
|
|
52
|
+
const exitCode = input.tool_input.exit_code;
|
|
53
|
+
|
|
54
|
+
// Format: [timestamp] exit_code command
|
|
55
|
+
// If exit_code is available, include it; otherwise just log the command
|
|
56
|
+
let logLine: string;
|
|
57
|
+
if (exitCode !== undefined && exitCode !== null) {
|
|
58
|
+
logLine = `[${timestamp}] exit=${exitCode} ${command}\n`;
|
|
59
|
+
} else {
|
|
60
|
+
logLine = `[${timestamp}] ${command}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
appendFileSync(logFile, logLine);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function run(): void {
|
|
67
|
+
const input = readStdinJson();
|
|
68
|
+
|
|
69
|
+
if (!input) {
|
|
70
|
+
respond({ continue: true });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only log Bash tool calls
|
|
75
|
+
if (input.tool_name !== "Bash") {
|
|
76
|
+
respond({ continue: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
logCommand(input);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
84
|
+
console.error(`[hook-commandlog] Warning: failed to log command: ${errMsg}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
respond({ continue: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (import.meta.main) {
|
|
91
|
+
run();
|
|
92
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# hook-costwatch
|
|
2
|
+
|
|
3
|
+
Claude Code hook that estimates session token usage and warns if a budget threshold is exceeded.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When Claude stops, this hook attempts to estimate the session's token usage by examining the transcript file size. If a budget is configured and the estimated cost exceeds it, a warning is logged to stderr.
|
|
8
|
+
|
|
9
|
+
## Event
|
|
10
|
+
|
|
11
|
+
- **Stop** (no matcher)
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
### Budget (optional)
|
|
16
|
+
|
|
17
|
+
Set a per-session budget via environment variable:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export COST_WATCH_BUDGET="5.00" # Max $5.00 per session
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If not set, the hook runs without budget enforcement and simply logs a reminder to check usage.
|
|
24
|
+
|
|
25
|
+
## Cost Estimation
|
|
26
|
+
|
|
27
|
+
The estimation is intentionally rough:
|
|
28
|
+
|
|
29
|
+
- **~4 characters per token** (average for English text)
|
|
30
|
+
- **~$30 per million tokens** (blended input/output estimate)
|
|
31
|
+
- Based on transcript file size, not actual API usage
|
|
32
|
+
|
|
33
|
+
This is a ballpark estimate. Always check actual usage at [console.anthropic.com](https://console.anthropic.com/).
|
|
34
|
+
|
|
35
|
+
## Behavior
|
|
36
|
+
|
|
37
|
+
- Reads the session transcript and estimates total tokens from file size
|
|
38
|
+
- If `COST_WATCH_BUDGET` is set and estimated cost exceeds it, logs a WARNING to stderr
|
|
39
|
+
- If no transcript is found, logs that cost could not be estimated
|
|
40
|
+
- Never blocks the session from ending
|
|
41
|
+
- Outputs `{ "continue": true }` always
|
|
42
|
+
|
|
43
|
+
## Example Output
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
[hook-costwatch] Session estimate: ~125.0K tokens, ~$3.75
|
|
47
|
+
[hook-costwatch] Budget: $5.00/session. Remember to check actual usage.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With budget exceeded:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
[hook-costwatch] Session estimate: ~250.0K tokens, ~$7.50
|
|
54
|
+
[hook-costwatch] WARNING: Estimated cost ($7.50) exceeds budget ($5.00)!
|
|
55
|
+
[hook-costwatch] Check your actual usage at https://console.anthropic.com/
|
|
56
|
+
[hook-costwatch] Budget: $5.00/session. Remember to check actual usage.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-costwatch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that estimates session cost and warns if budget is exceeded",
|
|
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,178 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: costwatch
|
|
5
|
+
*
|
|
6
|
+
* Stop hook that estimates session token usage and warns if a budget
|
|
7
|
+
* threshold is exceeded.
|
|
8
|
+
*
|
|
9
|
+
* Configuration:
|
|
10
|
+
* - Environment variable: COST_WATCH_BUDGET (max $ per session, e.g. "5.00")
|
|
11
|
+
* - If not set, no budget enforcement (just logs a reminder)
|
|
12
|
+
*
|
|
13
|
+
* Token estimation is rough:
|
|
14
|
+
* - ~4 characters per token (English text average)
|
|
15
|
+
* - Claude Opus pricing: ~$15/M input tokens, ~$75/M output tokens
|
|
16
|
+
* - We estimate a blended rate of ~$30/M tokens for simplicity
|
|
17
|
+
*
|
|
18
|
+
* Since the Stop event provides limited session info, this hook
|
|
19
|
+
* primarily serves as a reminder to check actual usage.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
|
|
25
|
+
interface HookInput {
|
|
26
|
+
session_id: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
hook_event_name: string;
|
|
29
|
+
transcript_path?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface HookOutput {
|
|
33
|
+
continue: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Approximate cost per million tokens (blended input/output estimate) */
|
|
37
|
+
const BLENDED_COST_PER_MILLION_TOKENS = 30;
|
|
38
|
+
|
|
39
|
+
/** Average characters per token */
|
|
40
|
+
const CHARS_PER_TOKEN = 4;
|
|
41
|
+
|
|
42
|
+
function readStdinJson(): HookInput | null {
|
|
43
|
+
try {
|
|
44
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
45
|
+
if (!input) return null;
|
|
46
|
+
return JSON.parse(input);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function respond(output: HookOutput): void {
|
|
53
|
+
console.log(JSON.stringify(output));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getBudget(): number | null {
|
|
57
|
+
const budgetStr = process.env.COST_WATCH_BUDGET;
|
|
58
|
+
if (!budgetStr) return null;
|
|
59
|
+
|
|
60
|
+
const budget = parseFloat(budgetStr);
|
|
61
|
+
if (isNaN(budget) || budget <= 0) {
|
|
62
|
+
console.error(
|
|
63
|
+
`[hook-costwatch] Invalid COST_WATCH_BUDGET value: "${budgetStr}". Must be a positive number.`
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return budget;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function estimateTranscriptCost(transcriptPath: string): {
|
|
71
|
+
charCount: number;
|
|
72
|
+
estimatedTokens: number;
|
|
73
|
+
estimatedCost: number;
|
|
74
|
+
} | null {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(transcriptPath)) return null;
|
|
77
|
+
|
|
78
|
+
const stat = statSync(transcriptPath);
|
|
79
|
+
const charCount = stat.size;
|
|
80
|
+
const estimatedTokens = Math.ceil(charCount / CHARS_PER_TOKEN);
|
|
81
|
+
const estimatedCost = (estimatedTokens / 1_000_000) * BLENDED_COST_PER_MILLION_TOKENS;
|
|
82
|
+
|
|
83
|
+
return { charCount, estimatedTokens, estimatedCost };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findSessionTranscript(cwd: string, sessionId: string): string | null {
|
|
90
|
+
// Check common transcript locations
|
|
91
|
+
const possibleDirs = [
|
|
92
|
+
join(cwd, ".claude"),
|
|
93
|
+
join(process.env.HOME || "", ".claude", "projects"),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const dir of possibleDirs) {
|
|
97
|
+
if (!existsSync(dir)) continue;
|
|
98
|
+
try {
|
|
99
|
+
const files = readdirSync(dir, { recursive: true }) as string[];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const filePath = join(dir, file);
|
|
102
|
+
if (typeof file === "string" && file.includes(sessionId)) {
|
|
103
|
+
return filePath;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Directory not readable, skip
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function run(): void {
|
|
115
|
+
const input = readStdinJson();
|
|
116
|
+
|
|
117
|
+
if (!input) {
|
|
118
|
+
respond({ continue: true });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const budget = getBudget();
|
|
123
|
+
|
|
124
|
+
// Try to estimate cost from transcript
|
|
125
|
+
let estimate: {
|
|
126
|
+
charCount: number;
|
|
127
|
+
estimatedTokens: number;
|
|
128
|
+
estimatedCost: number;
|
|
129
|
+
} | null = null;
|
|
130
|
+
|
|
131
|
+
if (input.transcript_path) {
|
|
132
|
+
estimate = estimateTranscriptCost(input.transcript_path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!estimate) {
|
|
136
|
+
// Try to find transcript by session ID
|
|
137
|
+
const transcriptPath = findSessionTranscript(input.cwd, input.session_id);
|
|
138
|
+
if (transcriptPath) {
|
|
139
|
+
estimate = estimateTranscriptCost(transcriptPath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (estimate) {
|
|
144
|
+
const costStr = estimate.estimatedCost.toFixed(2);
|
|
145
|
+
const tokensStr = (estimate.estimatedTokens / 1000).toFixed(1);
|
|
146
|
+
|
|
147
|
+
console.error(`[hook-costwatch] Session estimate: ~${tokensStr}K tokens, ~$${costStr}`);
|
|
148
|
+
|
|
149
|
+
if (budget !== null && estimate.estimatedCost > budget) {
|
|
150
|
+
console.error(
|
|
151
|
+
`[hook-costwatch] WARNING: Estimated cost ($${costStr}) exceeds budget ($${budget.toFixed(2)})!`
|
|
152
|
+
);
|
|
153
|
+
console.error(
|
|
154
|
+
`[hook-costwatch] Check your actual usage at https://console.anthropic.com/`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
console.error(
|
|
159
|
+
`[hook-costwatch] Could not estimate session cost (no transcript found).`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (budget !== null) {
|
|
164
|
+
console.error(
|
|
165
|
+
`[hook-costwatch] Budget: $${budget.toFixed(2)}/session. Remember to check actual usage.`
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
console.error(
|
|
169
|
+
`[hook-costwatch] No budget set. Set COST_WATCH_BUDGET env var to enable budget warnings.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
respond({ continue: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (import.meta.main) {
|
|
177
|
+
run();
|
|
178
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# hook-desktopnotify
|
|
2
|
+
|
|
3
|
+
Claude Code hook that sends native desktop notifications when Claude stops.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Get notified immediately when Claude finishes a task. Uses your OS's native notification system — no external services or accounts needed.
|
|
8
|
+
|
|
9
|
+
## Platform Support
|
|
10
|
+
|
|
11
|
+
| Platform | Method | Requirements |
|
|
12
|
+
|----------|--------|--------------|
|
|
13
|
+
| macOS | `osascript` (display notification) | None (built-in) |
|
|
14
|
+
| Linux | `notify-send` | `libnotify` package |
|
|
15
|
+
|
|
16
|
+
## Hook Event
|
|
17
|
+
|
|
18
|
+
- **Stop** (no matcher)
|
|
19
|
+
|
|
20
|
+
## Behavior
|
|
21
|
+
|
|
22
|
+
1. Fires when Claude stops and waits for input
|
|
23
|
+
2. Detects the current platform (`process.platform`)
|
|
24
|
+
3. Sends a native notification with the project name
|
|
25
|
+
4. Plays a sound on macOS (Glass)
|
|
26
|
+
5. Outputs `{ continue: true }`
|
|
27
|
+
|
|
28
|
+
## Notification Content
|
|
29
|
+
|
|
30
|
+
- **Title**: "Claude Code — Done"
|
|
31
|
+
- **Body**: "Claude has finished working on {project} and is waiting for your input."
|
|
32
|
+
|
|
33
|
+
## Linux Setup
|
|
34
|
+
|
|
35
|
+
If `notify-send` is not installed:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Ubuntu/Debian
|
|
39
|
+
sudo apt install libnotify-bin
|
|
40
|
+
|
|
41
|
+
# Fedora
|
|
42
|
+
sudo dnf install libnotify
|
|
43
|
+
|
|
44
|
+
# Arch
|
|
45
|
+
sudo pacman -S libnotify
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|