@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
package/dist/index.js
CHANGED
|
@@ -5,7 +5,12 @@ var CATEGORIES = [
|
|
|
5
5
|
"Code Quality",
|
|
6
6
|
"Security",
|
|
7
7
|
"Notifications",
|
|
8
|
-
"Context Management"
|
|
8
|
+
"Context Management",
|
|
9
|
+
"Workflow Automation",
|
|
10
|
+
"Environment",
|
|
11
|
+
"Permissions",
|
|
12
|
+
"Observability",
|
|
13
|
+
"Agent Teams"
|
|
9
14
|
];
|
|
10
15
|
var HOOKS = [
|
|
11
16
|
{
|
|
@@ -157,6 +162,156 @@ var HOOKS = [
|
|
|
157
162
|
event: "Notification",
|
|
158
163
|
matcher: "",
|
|
159
164
|
tags: ["context", "compaction", "state", "backup"]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "autoformat",
|
|
168
|
+
displayName: "Auto Format",
|
|
169
|
+
description: "Runs project formatter (Prettier, Biome, Ruff, Black, gofmt) after file edits",
|
|
170
|
+
version: "0.1.0",
|
|
171
|
+
category: "Workflow Automation",
|
|
172
|
+
event: "PostToolUse",
|
|
173
|
+
matcher: "Edit|Write",
|
|
174
|
+
tags: ["format", "prettier", "biome", "ruff", "black", "gofmt", "style"]
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: "autostage",
|
|
178
|
+
displayName: "Auto Stage",
|
|
179
|
+
description: "Automatically git-stages files after Claude edits them",
|
|
180
|
+
version: "0.1.0",
|
|
181
|
+
category: "Workflow Automation",
|
|
182
|
+
event: "PostToolUse",
|
|
183
|
+
matcher: "Edit|Write",
|
|
184
|
+
tags: ["git", "stage", "add", "auto"]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "tddguard",
|
|
188
|
+
displayName: "TDD Guard",
|
|
189
|
+
description: "Blocks implementation edits unless corresponding test files exist",
|
|
190
|
+
version: "0.1.0",
|
|
191
|
+
category: "Workflow Automation",
|
|
192
|
+
event: "PreToolUse",
|
|
193
|
+
matcher: "Edit|Write",
|
|
194
|
+
tags: ["tdd", "tests", "red-green-refactor", "enforcement"]
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "envsetup",
|
|
198
|
+
displayName: "Env Setup",
|
|
199
|
+
description: "Warns when nvm, virtualenv, asdf, or rbenv may need activation before commands",
|
|
200
|
+
version: "0.1.0",
|
|
201
|
+
category: "Environment",
|
|
202
|
+
event: "PreToolUse",
|
|
203
|
+
matcher: "Bash",
|
|
204
|
+
tags: ["nvm", "virtualenv", "asdf", "rbenv", "environment", "python", "node"]
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "permissionguard",
|
|
208
|
+
displayName: "Permission Guard",
|
|
209
|
+
description: "Auto-approves safe read-only commands and blocks dangerous operations",
|
|
210
|
+
version: "0.1.0",
|
|
211
|
+
category: "Permissions",
|
|
212
|
+
event: "PreToolUse",
|
|
213
|
+
matcher: "Bash",
|
|
214
|
+
tags: ["permission", "allowlist", "blocklist", "safety", "auto-approve"]
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "protectfiles",
|
|
218
|
+
displayName: "Protect Files",
|
|
219
|
+
description: "Blocks access to .env, secrets, SSH keys, and lock files",
|
|
220
|
+
version: "0.1.0",
|
|
221
|
+
category: "Permissions",
|
|
222
|
+
event: "PreToolUse",
|
|
223
|
+
matcher: "Edit|Write|Read|Bash",
|
|
224
|
+
tags: ["security", "env", "secrets", "keys", "lock-files", "protect"]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "promptguard",
|
|
228
|
+
displayName: "Prompt Guard",
|
|
229
|
+
description: "Blocks prompt injection attempts and credential access requests",
|
|
230
|
+
version: "0.1.0",
|
|
231
|
+
category: "Permissions",
|
|
232
|
+
event: "PreToolUse",
|
|
233
|
+
matcher: "",
|
|
234
|
+
tags: ["prompt", "injection", "security", "validation", "guard"]
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "desktopnotify",
|
|
238
|
+
displayName: "Desktop Notify",
|
|
239
|
+
description: "Sends native desktop notifications via osascript (macOS) or notify-send (Linux)",
|
|
240
|
+
version: "0.1.0",
|
|
241
|
+
category: "Notifications",
|
|
242
|
+
event: "Stop",
|
|
243
|
+
matcher: "",
|
|
244
|
+
tags: ["notification", "desktop", "macos", "linux", "native"]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "slacknotify",
|
|
248
|
+
displayName: "Slack Notify",
|
|
249
|
+
description: "Sends Slack webhook notifications when Claude finishes",
|
|
250
|
+
version: "0.1.0",
|
|
251
|
+
category: "Notifications",
|
|
252
|
+
event: "Stop",
|
|
253
|
+
matcher: "",
|
|
254
|
+
tags: ["notification", "slack", "webhook", "team"]
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "soundnotify",
|
|
258
|
+
displayName: "Sound Notify",
|
|
259
|
+
description: "Plays a system sound when Claude finishes (macOS/Linux)",
|
|
260
|
+
version: "0.1.0",
|
|
261
|
+
category: "Notifications",
|
|
262
|
+
event: "Stop",
|
|
263
|
+
matcher: "",
|
|
264
|
+
tags: ["notification", "sound", "audio", "alert"]
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "sessionlog",
|
|
268
|
+
displayName: "Session Log",
|
|
269
|
+
description: "Logs every tool call to .claude/session-log-<date>.jsonl",
|
|
270
|
+
version: "0.1.0",
|
|
271
|
+
category: "Observability",
|
|
272
|
+
event: "PostToolUse",
|
|
273
|
+
matcher: "",
|
|
274
|
+
tags: ["logging", "audit", "session", "history", "jsonl"]
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: "commandlog",
|
|
278
|
+
displayName: "Command Log",
|
|
279
|
+
description: "Logs every bash command Claude runs to .claude/commands.log",
|
|
280
|
+
version: "0.1.0",
|
|
281
|
+
category: "Observability",
|
|
282
|
+
event: "PostToolUse",
|
|
283
|
+
matcher: "Bash",
|
|
284
|
+
tags: ["logging", "bash", "commands", "audit"]
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "costwatch",
|
|
288
|
+
displayName: "Cost Watch",
|
|
289
|
+
description: "Estimates session token usage and warns when budget threshold is exceeded",
|
|
290
|
+
version: "0.1.0",
|
|
291
|
+
category: "Observability",
|
|
292
|
+
event: "Stop",
|
|
293
|
+
matcher: "",
|
|
294
|
+
tags: ["cost", "tokens", "budget", "usage", "monitoring"]
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: "errornotify",
|
|
298
|
+
displayName: "Error Notify",
|
|
299
|
+
description: "Detects tool failures and logs errors to .claude/errors.log",
|
|
300
|
+
version: "0.1.0",
|
|
301
|
+
category: "Observability",
|
|
302
|
+
event: "PostToolUse",
|
|
303
|
+
matcher: "",
|
|
304
|
+
tags: ["errors", "failures", "logging", "debugging"]
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: "taskgate",
|
|
308
|
+
displayName: "Task Gate",
|
|
309
|
+
description: "Validates task completion criteria before allowing tasks to be marked done",
|
|
310
|
+
version: "0.1.0",
|
|
311
|
+
category: "Agent Teams",
|
|
312
|
+
event: "PostToolUse",
|
|
313
|
+
matcher: "",
|
|
314
|
+
tags: ["tasks", "completion", "gate", "quality", "agent-teams"]
|
|
160
315
|
}
|
|
161
316
|
];
|
|
162
317
|
function getHooksByCategory(category) {
|
|
@@ -176,11 +331,31 @@ import { homedir } from "os";
|
|
|
176
331
|
import { fileURLToPath } from "url";
|
|
177
332
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
178
333
|
var HOOKS_DIR = existsSync(join(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join(__dirname2, "..", "..", "hooks") : join(__dirname2, "..", "hooks");
|
|
179
|
-
|
|
334
|
+
var EVENT_MAP = {
|
|
335
|
+
claude: {
|
|
336
|
+
PreToolUse: "PreToolUse",
|
|
337
|
+
PostToolUse: "PostToolUse",
|
|
338
|
+
Stop: "Stop",
|
|
339
|
+
Notification: "Notification"
|
|
340
|
+
},
|
|
341
|
+
gemini: {
|
|
342
|
+
PreToolUse: "BeforeTool",
|
|
343
|
+
PostToolUse: "AfterTool",
|
|
344
|
+
Stop: "AfterAgent",
|
|
345
|
+
Notification: "Notification"
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
function getTargetSettingsDir(target) {
|
|
349
|
+
if (target === "gemini")
|
|
350
|
+
return ".gemini";
|
|
351
|
+
return ".claude";
|
|
352
|
+
}
|
|
353
|
+
function getSettingsPath(scope = "global", target = "claude") {
|
|
354
|
+
const dir = getTargetSettingsDir(target);
|
|
180
355
|
if (scope === "project") {
|
|
181
|
-
return join(process.cwd(),
|
|
356
|
+
return join(process.cwd(), dir, "settings.json");
|
|
182
357
|
}
|
|
183
|
-
return join(homedir(),
|
|
358
|
+
return join(homedir(), dir, "settings.json");
|
|
184
359
|
}
|
|
185
360
|
function getHookPath(name) {
|
|
186
361
|
const hookName = name.startsWith("hook-") ? name : `hook-${name}`;
|
|
@@ -189,8 +364,8 @@ function getHookPath(name) {
|
|
|
189
364
|
function hookExists(name) {
|
|
190
365
|
return existsSync(getHookPath(name));
|
|
191
366
|
}
|
|
192
|
-
function readSettings(scope = "global") {
|
|
193
|
-
const path = getSettingsPath(scope);
|
|
367
|
+
function readSettings(scope = "global", target = "claude") {
|
|
368
|
+
const path = getSettingsPath(scope, target);
|
|
194
369
|
try {
|
|
195
370
|
if (existsSync(path)) {
|
|
196
371
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
@@ -198,8 +373,8 @@ function readSettings(scope = "global") {
|
|
|
198
373
|
} catch {}
|
|
199
374
|
return {};
|
|
200
375
|
}
|
|
201
|
-
function writeSettings(settings, scope = "global") {
|
|
202
|
-
const path = getSettingsPath(scope);
|
|
376
|
+
function writeSettings(settings, scope = "global", target = "claude") {
|
|
377
|
+
const path = getSettingsPath(scope, target);
|
|
203
378
|
const dir = dirname(path);
|
|
204
379
|
if (!existsSync(dir)) {
|
|
205
380
|
mkdirSync(dir, { recursive: true });
|
|
@@ -207,36 +382,48 @@ function writeSettings(settings, scope = "global") {
|
|
|
207
382
|
writeFileSync(path, JSON.stringify(settings, null, 2) + `
|
|
208
383
|
`);
|
|
209
384
|
}
|
|
210
|
-
function
|
|
211
|
-
|
|
385
|
+
function getTargetEventName(internalEvent, target) {
|
|
386
|
+
return EVENT_MAP[target]?.[internalEvent] || internalEvent;
|
|
387
|
+
}
|
|
388
|
+
function installForTarget(name, scope, overwrite, target) {
|
|
212
389
|
const hookName = name.startsWith("hook-") ? name : `hook-${name}`;
|
|
213
390
|
const shortName = hookName.replace("hook-", "");
|
|
214
391
|
if (!hookExists(shortName)) {
|
|
215
|
-
return { hook: shortName, success: false, error: `Hook '${shortName}' not found
|
|
392
|
+
return { hook: shortName, success: false, error: `Hook '${shortName}' not found`, target };
|
|
216
393
|
}
|
|
217
|
-
const registered =
|
|
394
|
+
const registered = getRegisteredHooksForTarget(scope, target);
|
|
218
395
|
if (registered.includes(shortName) && !overwrite) {
|
|
219
|
-
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope };
|
|
396
|
+
return { hook: shortName, success: false, error: "Already installed. Use --overwrite to replace.", scope, target };
|
|
220
397
|
}
|
|
221
398
|
try {
|
|
222
|
-
registerHook(shortName, scope);
|
|
223
|
-
return { hook: shortName, success: true, scope };
|
|
399
|
+
registerHook(shortName, scope, target);
|
|
400
|
+
return { hook: shortName, success: true, scope, target };
|
|
224
401
|
} catch (error) {
|
|
225
402
|
return {
|
|
226
403
|
hook: shortName,
|
|
227
404
|
success: false,
|
|
228
|
-
error: error instanceof Error ? error.message : "Unknown error"
|
|
405
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
406
|
+
target
|
|
229
407
|
};
|
|
230
408
|
}
|
|
231
409
|
}
|
|
232
|
-
function
|
|
410
|
+
function installHook(name, options = {}) {
|
|
411
|
+
const { scope = "global", overwrite = false, target = "claude" } = options;
|
|
412
|
+
if (target === "all") {
|
|
413
|
+
const claudeResult = installForTarget(name, scope, overwrite, "claude");
|
|
414
|
+
installForTarget(name, scope, overwrite, "gemini");
|
|
415
|
+
return { ...claudeResult, target: "all" };
|
|
416
|
+
}
|
|
417
|
+
return installForTarget(name, scope, overwrite, target);
|
|
418
|
+
}
|
|
419
|
+
function registerHook(name, scope = "global", target = "claude") {
|
|
233
420
|
const meta = getHook(name);
|
|
234
421
|
if (!meta)
|
|
235
422
|
return;
|
|
236
|
-
const settings = readSettings(scope);
|
|
423
|
+
const settings = readSettings(scope, target);
|
|
237
424
|
if (!settings.hooks)
|
|
238
425
|
settings.hooks = {};
|
|
239
|
-
const eventKey = meta.event;
|
|
426
|
+
const eventKey = getTargetEventName(meta.event, target);
|
|
240
427
|
if (!settings.hooks[eventKey])
|
|
241
428
|
settings.hooks[eventKey] = [];
|
|
242
429
|
const hookCommand = `hooks run ${name}`;
|
|
@@ -248,16 +435,16 @@ function registerHook(name, scope = "global") {
|
|
|
248
435
|
entry.matcher = meta.matcher;
|
|
249
436
|
}
|
|
250
437
|
settings.hooks[eventKey].push(entry);
|
|
251
|
-
writeSettings(settings, scope);
|
|
438
|
+
writeSettings(settings, scope, target);
|
|
252
439
|
}
|
|
253
|
-
function unregisterHook(name, scope = "global") {
|
|
440
|
+
function unregisterHook(name, scope = "global", target = "claude") {
|
|
254
441
|
const meta = getHook(name);
|
|
255
442
|
if (!meta)
|
|
256
443
|
return;
|
|
257
|
-
const settings = readSettings(scope);
|
|
444
|
+
const settings = readSettings(scope, target);
|
|
258
445
|
if (!settings.hooks)
|
|
259
446
|
return;
|
|
260
|
-
const eventKey = meta.event;
|
|
447
|
+
const eventKey = getTargetEventName(meta.event, target);
|
|
261
448
|
if (!settings.hooks[eventKey])
|
|
262
449
|
return;
|
|
263
450
|
const hookCommand = `hooks run ${name}`;
|
|
@@ -268,13 +455,13 @@ function unregisterHook(name, scope = "global") {
|
|
|
268
455
|
if (Object.keys(settings.hooks).length === 0) {
|
|
269
456
|
delete settings.hooks;
|
|
270
457
|
}
|
|
271
|
-
writeSettings(settings, scope);
|
|
458
|
+
writeSettings(settings, scope, target);
|
|
272
459
|
}
|
|
273
460
|
function installHooks(names, options = {}) {
|
|
274
461
|
return names.map((name) => installHook(name, options));
|
|
275
462
|
}
|
|
276
|
-
function
|
|
277
|
-
const settings = readSettings(scope);
|
|
463
|
+
function getRegisteredHooksForTarget(scope = "global", target = "claude") {
|
|
464
|
+
const settings = readSettings(scope, target);
|
|
278
465
|
if (!settings.hooks)
|
|
279
466
|
return [];
|
|
280
467
|
const registered = [];
|
|
@@ -292,17 +479,28 @@ function getRegisteredHooks(scope = "global") {
|
|
|
292
479
|
}
|
|
293
480
|
return [...new Set(registered)];
|
|
294
481
|
}
|
|
482
|
+
function getRegisteredHooks(scope = "global") {
|
|
483
|
+
return getRegisteredHooksForTarget(scope, "claude");
|
|
484
|
+
}
|
|
295
485
|
function getInstalledHooks(scope = "global") {
|
|
296
486
|
return getRegisteredHooks(scope);
|
|
297
487
|
}
|
|
298
|
-
function removeHook(name, scope = "global") {
|
|
488
|
+
function removeHook(name, scope = "global", target = "claude") {
|
|
299
489
|
const hookName = name.startsWith("hook-") ? name : `hook-${name}`;
|
|
300
490
|
const shortName = hookName.replace("hook-", "");
|
|
301
|
-
|
|
302
|
-
|
|
491
|
+
if (target === "all") {
|
|
492
|
+
const claudeRemoved = removeHookForTarget(shortName, scope, "claude");
|
|
493
|
+
const geminiRemoved = removeHookForTarget(shortName, scope, "gemini");
|
|
494
|
+
return claudeRemoved || geminiRemoved;
|
|
495
|
+
}
|
|
496
|
+
return removeHookForTarget(shortName, scope, target);
|
|
497
|
+
}
|
|
498
|
+
function removeHookForTarget(name, scope, target) {
|
|
499
|
+
const registered = getRegisteredHooksForTarget(scope, target);
|
|
500
|
+
if (!registered.includes(name)) {
|
|
303
501
|
return false;
|
|
304
502
|
}
|
|
305
|
-
unregisterHook(
|
|
503
|
+
unregisterHook(name, scope, target);
|
|
306
504
|
return true;
|
|
307
505
|
}
|
|
308
506
|
export {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# hook-autoformat
|
|
2
|
+
|
|
3
|
+
Claude Code hook that automatically runs the project's formatter after file edits.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Detects and runs the appropriate formatter whenever Claude edits or writes a file. No configuration needed — it reads your project's existing formatter config.
|
|
8
|
+
|
|
9
|
+
## Supported Formatters
|
|
10
|
+
|
|
11
|
+
| Config File | Formatter | File Types |
|
|
12
|
+
|-------------|-----------|------------|
|
|
13
|
+
| `.prettierrc` / `prettier` in package.json | Prettier | JS, TS, CSS, HTML, MD, JSON, YAML, etc. |
|
|
14
|
+
| `biome.json` | Biome | JS, TS, JSON, CSS, GraphQL |
|
|
15
|
+
| `pyproject.toml` with `[tool.ruff]` | Ruff | Python |
|
|
16
|
+
| `pyproject.toml` with `[tool.black]` | Black | Python |
|
|
17
|
+
| `.clang-format` | clang-format | C, C++, Obj-C |
|
|
18
|
+
| (any `.go` file) | gofmt | Go |
|
|
19
|
+
|
|
20
|
+
## Hook Event
|
|
21
|
+
|
|
22
|
+
- **PostToolUse** (matcher: `Edit|Write`)
|
|
23
|
+
|
|
24
|
+
## Behavior
|
|
25
|
+
|
|
26
|
+
1. Fires after every `Edit` or `Write` tool call
|
|
27
|
+
2. Reads `tool_input.file_path` to get the edited file
|
|
28
|
+
3. Detects the project formatter from config files in the working directory
|
|
29
|
+
4. Runs the formatter as a subprocess
|
|
30
|
+
5. Logs the result to stderr
|
|
31
|
+
6. Always outputs `{ continue: true }`
|
|
32
|
+
|
|
33
|
+
## Priority
|
|
34
|
+
|
|
35
|
+
If both Biome and Prettier configs exist, Biome takes priority (it's faster).
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna/hook-autoformat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code hook that auto-runs formatters after file edits",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hook-autoformat": "./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
|
+
"formatter",
|
|
33
|
+
"autoformat",
|
|
34
|
+
"prettier",
|
|
35
|
+
"biome",
|
|
36
|
+
"ruff",
|
|
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,223 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: autoformat
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that auto-runs the project's formatter after file edits.
|
|
7
|
+
* Detects the formatter from project config files:
|
|
8
|
+
*
|
|
9
|
+
* - .prettierrc / prettier in package.json → bunx prettier --write <file>
|
|
10
|
+
* - biome.json → bunx biome format --write <file>
|
|
11
|
+
* - pyproject.toml with [tool.ruff] or [tool.black] → ruff format / black
|
|
12
|
+
* - .clang-format → clang-format -i <file>
|
|
13
|
+
* - Go files (.go) → gofmt -w <file>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from "fs";
|
|
17
|
+
import { join, extname } from "path";
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
|
|
20
|
+
interface HookInput {
|
|
21
|
+
session_id: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
tool_name: string;
|
|
24
|
+
tool_input: Record<string, unknown>;
|
|
25
|
+
tool_output?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HookOutput {
|
|
29
|
+
continue: boolean;
|
|
30
|
+
}
|
|
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
|
+
function respond(output: HookOutput): void {
|
|
43
|
+
console.log(JSON.stringify(output));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hasPrettierConfig(cwd: string): boolean {
|
|
47
|
+
const configFiles = [
|
|
48
|
+
".prettierrc",
|
|
49
|
+
".prettierrc.json",
|
|
50
|
+
".prettierrc.yml",
|
|
51
|
+
".prettierrc.yaml",
|
|
52
|
+
".prettierrc.js",
|
|
53
|
+
".prettierrc.cjs",
|
|
54
|
+
".prettierrc.mjs",
|
|
55
|
+
"prettier.config.js",
|
|
56
|
+
"prettier.config.cjs",
|
|
57
|
+
"prettier.config.mjs",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
for (const file of configFiles) {
|
|
61
|
+
if (existsSync(join(cwd, file))) return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check package.json for prettier key
|
|
65
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
66
|
+
if (existsSync(packageJsonPath)) {
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
69
|
+
if (pkg.prettier) return true;
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasBiomeConfig(cwd: string): boolean {
|
|
79
|
+
return existsSync(join(cwd, "biome.json")) || existsSync(join(cwd, "biome.jsonc"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getPythonFormatter(cwd: string): "ruff" | "black" | null {
|
|
83
|
+
const pyprojectPath = join(cwd, "pyproject.toml");
|
|
84
|
+
if (!existsSync(pyprojectPath)) return null;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(pyprojectPath, "utf-8");
|
|
88
|
+
if (content.includes("[tool.ruff]")) return "ruff";
|
|
89
|
+
if (content.includes("[tool.black]")) return "black";
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasClangFormat(cwd: string): boolean {
|
|
98
|
+
return existsSync(join(cwd, ".clang-format"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isGoFile(filePath: string): boolean {
|
|
102
|
+
return extname(filePath) === ".go";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isPythonFile(filePath: string): boolean {
|
|
106
|
+
return extname(filePath) === ".py";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isFormattableByPrettier(filePath: string): boolean {
|
|
110
|
+
const ext = extname(filePath).toLowerCase();
|
|
111
|
+
const prettierExts = [
|
|
112
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
|
113
|
+
".json", ".css", ".scss", ".less", ".html",
|
|
114
|
+
".md", ".mdx", ".yaml", ".yml", ".graphql",
|
|
115
|
+
".vue", ".svelte",
|
|
116
|
+
];
|
|
117
|
+
return prettierExts.includes(ext);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isFormattableByBiome(filePath: string): boolean {
|
|
121
|
+
const ext = extname(filePath).toLowerCase();
|
|
122
|
+
const biomeExts = [
|
|
123
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
|
124
|
+
".json", ".jsonc", ".css", ".graphql",
|
|
125
|
+
];
|
|
126
|
+
return biomeExts.includes(ext);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isClangFormattable(filePath: string): boolean {
|
|
130
|
+
const ext = extname(filePath).toLowerCase();
|
|
131
|
+
const clangExts = [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx", ".m", ".mm"];
|
|
132
|
+
return clangExts.includes(ext);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectFormatter(cwd: string, filePath: string): { name: string; command: string } | null {
|
|
136
|
+
// Go files always use gofmt
|
|
137
|
+
if (isGoFile(filePath)) {
|
|
138
|
+
return { name: "gofmt", command: `gofmt -w "${filePath}"` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Python files
|
|
142
|
+
if (isPythonFile(filePath)) {
|
|
143
|
+
const pyFormatter = getPythonFormatter(cwd);
|
|
144
|
+
if (pyFormatter === "ruff") {
|
|
145
|
+
return { name: "ruff", command: `ruff format "${filePath}"` };
|
|
146
|
+
}
|
|
147
|
+
if (pyFormatter === "black") {
|
|
148
|
+
return { name: "black", command: `black "${filePath}"` };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// C/C++ files with .clang-format
|
|
154
|
+
if (isClangFormattable(filePath) && hasClangFormat(cwd)) {
|
|
155
|
+
return { name: "clang-format", command: `clang-format -i "${filePath}"` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Biome takes priority over Prettier if both exist (it's faster)
|
|
159
|
+
if (hasBiomeConfig(cwd) && isFormattableByBiome(filePath)) {
|
|
160
|
+
return { name: "biome", command: `bunx @biomejs/biome format --write "${filePath}"` };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Prettier
|
|
164
|
+
if (hasPrettierConfig(cwd) && isFormattableByPrettier(filePath)) {
|
|
165
|
+
return { name: "prettier", command: `bunx prettier --write "${filePath}"` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function runFormatter(cwd: string, name: string, command: string): void {
|
|
172
|
+
try {
|
|
173
|
+
execSync(command, {
|
|
174
|
+
cwd,
|
|
175
|
+
encoding: "utf-8",
|
|
176
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
177
|
+
timeout: 30000, // 30s timeout
|
|
178
|
+
});
|
|
179
|
+
console.error(`[hook-autoformat] Formatted with ${name}`);
|
|
180
|
+
} catch (error: unknown) {
|
|
181
|
+
const execError = error as { stderr?: string; message?: string };
|
|
182
|
+
const errorMsg = execError.stderr || execError.message || "unknown error";
|
|
183
|
+
console.error(`[hook-autoformat] ${name} failed: ${errorMsg}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function run(): void {
|
|
188
|
+
const input = readStdinJson();
|
|
189
|
+
|
|
190
|
+
if (!input) {
|
|
191
|
+
respond({ continue: true });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Only process Edit and Write tools
|
|
196
|
+
if (input.tool_name !== "Edit" && input.tool_name !== "Write") {
|
|
197
|
+
respond({ continue: true });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const filePath = input.tool_input?.file_path as string;
|
|
202
|
+
if (!filePath || typeof filePath !== "string") {
|
|
203
|
+
respond({ continue: true });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cwd = input.cwd || process.cwd();
|
|
208
|
+
const formatter = detectFormatter(cwd, filePath);
|
|
209
|
+
|
|
210
|
+
if (!formatter) {
|
|
211
|
+
respond({ continue: true });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.error(`[hook-autoformat] Running ${formatter.name} on ${filePath}`);
|
|
216
|
+
runFormatter(cwd, formatter.name, formatter.command);
|
|
217
|
+
|
|
218
|
+
respond({ continue: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (import.meta.main) {
|
|
222
|
+
run();
|
|
223
|
+
}
|