claude-code-hookkit 1.0.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/dist/add-O4OSFQ76.js +140 -0
  4. package/dist/chunk-2BZZUQQ3.js +34 -0
  5. package/dist/chunk-LRXKKJDU.js +101 -0
  6. package/dist/chunk-PEDGREZY.js +46 -0
  7. package/dist/chunk-QKT647BI.js +30 -0
  8. package/dist/chunk-XLX5K6TZ.js +113 -0
  9. package/dist/cli.js +76 -0
  10. package/dist/create-DBLA6PTS.js +268 -0
  11. package/dist/doctor-UBK2C2TW.js +137 -0
  12. package/dist/info-FLYMAHDX.js +84 -0
  13. package/dist/init-RHEFGGUF.js +70 -0
  14. package/dist/list-SCSGYOBR.js +54 -0
  15. package/dist/remove-Z5QIW45P.js +109 -0
  16. package/dist/restore-7JQ3CHWZ.js +31 -0
  17. package/dist/test-ZRRLZ62R.js +194 -0
  18. package/package.json +59 -0
  19. package/registry/hooks/cost-tracker.sh +44 -0
  20. package/registry/hooks/error-advisor.sh +114 -0
  21. package/registry/hooks/exit-code-enforcer.sh +76 -0
  22. package/registry/hooks/fixtures/cost-tracker/allow-bash-tool.json +5 -0
  23. package/registry/hooks/fixtures/cost-tracker/allow-no-session.json +5 -0
  24. package/registry/hooks/fixtures/error-advisor/allow-enoent.json +5 -0
  25. package/registry/hooks/fixtures/error-advisor/allow-no-error.json +5 -0
  26. package/registry/hooks/fixtures/exit-code-enforcer/allow-npm-test.json +5 -0
  27. package/registry/hooks/fixtures/exit-code-enforcer/block-rm-rf.json +5 -0
  28. package/registry/hooks/fixtures/post-edit-lint/allow-ts-file.json +5 -0
  29. package/registry/hooks/fixtures/post-edit-lint/allow-unknown-ext.json +5 -0
  30. package/registry/hooks/fixtures/sensitive-path-guard/allow-src.json +5 -0
  31. package/registry/hooks/fixtures/sensitive-path-guard/block-env.json +5 -0
  32. package/registry/hooks/fixtures/ts-check/allow-non-ts.json +5 -0
  33. package/registry/hooks/fixtures/ts-check/allow-ts-file.json +5 -0
  34. package/registry/hooks/fixtures/web-budget-gate/allow-within-budget.json +6 -0
  35. package/registry/hooks/fixtures/web-budget-gate/block-over-budget.json +6 -0
  36. package/registry/hooks/post-edit-lint.sh +82 -0
  37. package/registry/hooks/sensitive-path-guard.sh +103 -0
  38. package/registry/hooks/ts-check.sh +98 -0
  39. package/registry/hooks/web-budget-gate.sh +60 -0
  40. package/registry/registry.json +81 -0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-2BZZUQQ3.js";
5
+ import {
6
+ readSettings,
7
+ writeSettings
8
+ } from "./chunk-LRXKKJDU.js";
9
+ import {
10
+ createBackup
11
+ } from "./chunk-QKT647BI.js";
12
+ import {
13
+ getHooksDir,
14
+ getSettingsPath
15
+ } from "./chunk-PEDGREZY.js";
16
+
17
+ // src/commands/init.ts
18
+ import { mkdir } from "fs/promises";
19
+ import { existsSync } from "fs";
20
+ import { dirname as pathDirname } from "path";
21
+ async function _initAt(opts) {
22
+ const { settingsPath, hooksDir, dryRun = false } = opts;
23
+ const existing = await readSettings(settingsPath);
24
+ if (existing.hooks !== void 0) {
25
+ const hookCount = Object.keys(existing.hooks).length;
26
+ if (hookCount > 0) {
27
+ log.info(`Already initialized at ${settingsPath}`);
28
+ return;
29
+ }
30
+ if (!dryRun) {
31
+ await mkdir(hooksDir, { recursive: true });
32
+ }
33
+ return;
34
+ }
35
+ if (dryRun) {
36
+ log.dryRun(`Would create directory: ${hooksDir}`);
37
+ log.dryRun(`Would seed settings.json at: ${settingsPath}`);
38
+ log.dryRun("Would add empty hooks structure");
39
+ return;
40
+ }
41
+ const hooksDirCreated = !existsSync(hooksDir);
42
+ await mkdir(hooksDir, { recursive: true });
43
+ const settingsParent = pathDirname(settingsPath);
44
+ await mkdir(settingsParent, { recursive: true });
45
+ const backupPath = await createBackup(settingsPath);
46
+ const newSettings = { ...existing, hooks: {} };
47
+ await writeSettings(settingsPath, newSettings);
48
+ if (hooksDirCreated) {
49
+ log.success(`Created ${hooksDir}`);
50
+ }
51
+ log.success(`Seeded ${settingsPath} with hooks configuration`);
52
+ if (backupPath) {
53
+ log.dim(` Backed up existing settings to ${backupPath}`);
54
+ }
55
+ }
56
+ async function initCommand(opts) {
57
+ const validScopes = ["user", "project", "local"];
58
+ const scope = validScopes.includes(opts.scope) ? opts.scope : "project";
59
+ const resolvedSettingsPath = getSettingsPath(scope);
60
+ const resolvedHooksDir = getHooksDir(scope);
61
+ await _initAt({
62
+ settingsPath: resolvedSettingsPath,
63
+ hooksDir: resolvedHooksDir,
64
+ dryRun: opts.dryRun
65
+ });
66
+ }
67
+ export {
68
+ _initAt,
69
+ initCommand
70
+ };
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ listHooks
4
+ } from "./chunk-XLX5K6TZ.js";
5
+ import {
6
+ getHooksDir
7
+ } from "./chunk-PEDGREZY.js";
8
+
9
+ // src/commands/list.ts
10
+ import { existsSync } from "fs";
11
+ import { join } from "path";
12
+ import pc from "picocolors";
13
+ function pad(str, width) {
14
+ return str.length >= width ? str : str + " ".repeat(width - str.length);
15
+ }
16
+ function stripAnsi(str) {
17
+ return str.replace(/\x1B\[[0-9;]*m/g, "");
18
+ }
19
+ function padColored(str, width) {
20
+ const visibleLen = stripAnsi(str).length;
21
+ const padding = Math.max(0, width - visibleLen);
22
+ return str + " ".repeat(padding);
23
+ }
24
+ async function _listAt(opts) {
25
+ const { hooksDir } = opts;
26
+ const hooks = listHooks();
27
+ const COL_NAME = 26;
28
+ const COL_EVENT = 18;
29
+ const COL_MATCHER = 22;
30
+ const COL_PACK = 16;
31
+ const COL_INSTALLED = 9;
32
+ const header = pad("Name", COL_NAME) + pad("Event", COL_EVENT) + pad("Matcher", COL_MATCHER) + pad("Pack", COL_PACK) + "Installed";
33
+ console.log(pc.bold(header));
34
+ console.log(pc.dim("-".repeat(COL_NAME + COL_EVENT + COL_MATCHER + COL_PACK + COL_INSTALLED)));
35
+ for (const hook of hooks) {
36
+ const scriptPath = join(hooksDir, hook.scriptFile);
37
+ const installed = existsSync(scriptPath);
38
+ const name = padColored(installed ? pc.green(hook.name) : hook.name, COL_NAME);
39
+ const event = pad(hook.event, COL_EVENT);
40
+ const matcher = pad(hook.matcher ?? "-", COL_MATCHER);
41
+ const pack = pad(hook.pack ?? "-", COL_PACK);
42
+ const installedStr = installed ? pc.green("yes") : pc.dim("no");
43
+ console.log(name + event + matcher + pack + installedStr);
44
+ }
45
+ }
46
+ async function listCommand(opts) {
47
+ const validScopes = ["user", "project", "local"];
48
+ const scope = validScopes.includes(opts.scope) ? opts.scope : "project";
49
+ await _listAt({ hooksDir: getHooksDir(scope) });
50
+ }
51
+ export {
52
+ _listAt,
53
+ listCommand
54
+ };
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-2BZZUQQ3.js";
5
+ import {
6
+ readSettings,
7
+ writeSettings
8
+ } from "./chunk-LRXKKJDU.js";
9
+ import {
10
+ createBackup
11
+ } from "./chunk-QKT647BI.js";
12
+ import {
13
+ getHook
14
+ } from "./chunk-XLX5K6TZ.js";
15
+ import {
16
+ getHooksDir,
17
+ getSettingsPath
18
+ } from "./chunk-PEDGREZY.js";
19
+
20
+ // src/commands/remove.ts
21
+ import { unlink, readFile, mkdir } from "fs/promises";
22
+ import { existsSync } from "fs";
23
+ import { join, dirname as pathDirname } from "path";
24
+ function removeHookFromSettings(settings, exactScriptPath) {
25
+ if (!settings.hooks) {
26
+ return { settings, removedCount: 0 };
27
+ }
28
+ const modified = structuredClone(settings);
29
+ let removedCount = 0;
30
+ for (const [event, groups] of Object.entries(modified.hooks)) {
31
+ const filtered = [];
32
+ for (const group of groups) {
33
+ const remainingHooks = group.hooks.filter((h) => {
34
+ const matches = h.command === exactScriptPath;
35
+ if (matches) removedCount++;
36
+ return !matches;
37
+ });
38
+ if (remainingHooks.length > 0) {
39
+ filtered.push({ ...group, hooks: remainingHooks });
40
+ }
41
+ }
42
+ modified.hooks[event] = filtered;
43
+ }
44
+ return { settings: modified, removedCount };
45
+ }
46
+ async function _removeAt(opts) {
47
+ const { settingsPath, hooksDir, hookName, dryRun = false } = opts;
48
+ const hook = getHook(hookName);
49
+ if (!hook) {
50
+ log.error(`Unknown hook: "${hookName}". Run "claude-code-hookkit list" to see available hooks.`);
51
+ return;
52
+ }
53
+ const scriptPath = join(hooksDir, hook.scriptFile);
54
+ const scriptExists = existsSync(scriptPath);
55
+ const existing = await readSettings(settingsPath);
56
+ const { settings: updated, removedCount } = removeHookFromSettings(existing, scriptPath);
57
+ if (!scriptExists && removedCount === 0) {
58
+ log.warn(`Hook "${hookName}" is not installed.`);
59
+ return;
60
+ }
61
+ if (dryRun) {
62
+ if (scriptExists) {
63
+ log.dryRun(`Would delete: ${scriptPath}`);
64
+ }
65
+ if (removedCount > 0) {
66
+ log.dryRun(`Would remove ${removedCount} settings entr${removedCount === 1 ? "y" : "ies"} from settings.json`);
67
+ }
68
+ return;
69
+ }
70
+ if (removedCount > 0 && existsSync(settingsPath)) {
71
+ await createBackup(settingsPath);
72
+ let originalRaw;
73
+ try {
74
+ originalRaw = await readFile(settingsPath, "utf8");
75
+ } catch {
76
+ originalRaw = void 0;
77
+ }
78
+ const settingsParent = pathDirname(settingsPath);
79
+ await mkdir(settingsParent, { recursive: true });
80
+ await writeSettings(settingsPath, updated, originalRaw);
81
+ }
82
+ if (scriptExists) {
83
+ await unlink(scriptPath);
84
+ }
85
+ log.success(`Removed hook: ${hookName}`);
86
+ if (scriptExists) {
87
+ log.dim(` Deleted: ${scriptPath}`);
88
+ }
89
+ if (removedCount > 0) {
90
+ log.dim(` Removed ${removedCount} settings entr${removedCount === 1 ? "y" : "ies"} from settings.json`);
91
+ }
92
+ if (!scriptExists && removedCount > 0) {
93
+ log.dim(` (Script was already missing \u2014 cleaned up settings entry)`);
94
+ }
95
+ }
96
+ async function removeCommand(opts) {
97
+ const validScopes = ["user", "project", "local"];
98
+ const scope = validScopes.includes(opts.scope) ? opts.scope : "project";
99
+ await _removeAt({
100
+ settingsPath: getSettingsPath(scope),
101
+ hooksDir: getHooksDir(scope),
102
+ hookName: opts.hookName,
103
+ dryRun: opts.dryRun
104
+ });
105
+ }
106
+ export {
107
+ _removeAt,
108
+ removeCommand
109
+ };
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-2BZZUQQ3.js";
5
+ import {
6
+ restoreBackup
7
+ } from "./chunk-QKT647BI.js";
8
+ import {
9
+ getSettingsPath
10
+ } from "./chunk-PEDGREZY.js";
11
+
12
+ // src/commands/restore.ts
13
+ async function restoreCommand(opts) {
14
+ const scope = opts.scope;
15
+ const settingsPath = getSettingsPath(scope);
16
+ const backupPath = settingsPath + ".backup";
17
+ const success = await restoreBackup(settingsPath);
18
+ if (success) {
19
+ log.success(`Restored settings from backup`);
20
+ log.dim(` settings: ${settingsPath}`);
21
+ log.dim(` restored from: ${backupPath}`);
22
+ process.exit(0);
23
+ } else {
24
+ log.error(`No backup found for scope '${scope}'`);
25
+ log.dim(` expected: ${backupPath}`);
26
+ process.exit(1);
27
+ }
28
+ }
29
+ export {
30
+ restoreCommand
31
+ };
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getHook,
4
+ listHooks
5
+ } from "./chunk-XLX5K6TZ.js";
6
+ import {
7
+ getHooksDir
8
+ } from "./chunk-PEDGREZY.js";
9
+
10
+ // src/commands/test.ts
11
+ import { spawnSync } from "child_process";
12
+ import { readdir, readFile } from "fs/promises";
13
+ import { existsSync } from "fs";
14
+ import { fileURLToPath } from "url";
15
+ import { dirname, join, resolve } from "path";
16
+ import pc from "picocolors";
17
+ var __filename = fileURLToPath(import.meta.url);
18
+ var __dirname = dirname(__filename);
19
+ function findPackageRoot() {
20
+ let dir = __dirname;
21
+ for (let i = 0; i < 5; i++) {
22
+ if (existsSync(join(dir, "registry", "hooks", "fixtures"))) return dir;
23
+ const parent = resolve(dir, "..");
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ return resolve(__dirname, "..", "..");
28
+ }
29
+ var PACKAGE_ROOT = findPackageRoot();
30
+ var BUNDLED_HOOKS_DIR = join(PACKAGE_ROOT, "registry", "hooks");
31
+ var BUNDLED_FIXTURES_DIR = join(PACKAGE_ROOT, "registry", "hooks", "fixtures");
32
+ async function discoverFixtures(fixtureDir) {
33
+ if (!existsSync(fixtureDir)) {
34
+ return [];
35
+ }
36
+ let entries;
37
+ try {
38
+ entries = await readdir(fixtureDir);
39
+ } catch {
40
+ return [];
41
+ }
42
+ const fixtures = [];
43
+ for (const entry of entries) {
44
+ if (!entry.endsWith(".json")) continue;
45
+ const filePath = join(fixtureDir, entry);
46
+ try {
47
+ const raw = await readFile(filePath, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ fixtures.push(parsed);
50
+ } catch {
51
+ }
52
+ }
53
+ return fixtures;
54
+ }
55
+ async function runFixture(scriptPath, fixture, opts = {}) {
56
+ const hookName = opts.hookName ?? "";
57
+ const input = JSON.stringify(fixture.input);
58
+ const result = spawnSync("bash", [scriptPath], {
59
+ input,
60
+ encoding: "utf8",
61
+ env: { ...process.env, ...fixture.env ?? {} }
62
+ });
63
+ const actualExitCode = result.status ?? -1;
64
+ const passed = actualExitCode === fixture.expectedExitCode;
65
+ return {
66
+ hookName,
67
+ description: fixture.description,
68
+ expectedExitCode: fixture.expectedExitCode,
69
+ actualExitCode,
70
+ passed,
71
+ stdout: result.stdout ?? "",
72
+ stderr: result.stderr ?? ""
73
+ };
74
+ }
75
+ function resolveBundledScriptPath(scriptFile) {
76
+ return join(BUNDLED_HOOKS_DIR, scriptFile);
77
+ }
78
+ async function testHook(hookName, scope) {
79
+ const def = getHook(hookName);
80
+ const userHooksDir = getHooksDir(scope);
81
+ let scriptPath;
82
+ if (def) {
83
+ scriptPath = resolveBundledScriptPath(def.scriptFile);
84
+ } else {
85
+ const userScript = join(userHooksDir, `${hookName}.sh`);
86
+ if (existsSync(userScript)) {
87
+ scriptPath = userScript;
88
+ } else {
89
+ console.error(pc.red(`Hook not found: ${hookName}`));
90
+ console.error(pc.dim(" Not in registry and no script at: " + userScript));
91
+ console.error(pc.dim(" Run `claude-code-hookkit list` to see available hooks"));
92
+ process.exitCode = 1;
93
+ return [];
94
+ }
95
+ }
96
+ const bundledFixtureDir = join(BUNDLED_FIXTURES_DIR, hookName);
97
+ const userFixtureDir = join(userHooksDir, "fixtures", hookName);
98
+ const bundledFixtures = await discoverFixtures(bundledFixtureDir);
99
+ const userFixtures = await discoverFixtures(userFixtureDir);
100
+ const allFixtures = [...bundledFixtures, ...userFixtures];
101
+ if (allFixtures.length === 0) {
102
+ console.log(pc.yellow(` warning`) + ` ${hookName}: no fixtures found`);
103
+ console.log(pc.dim(` Add fixtures at: ${userFixtureDir}/*.json`));
104
+ return [];
105
+ }
106
+ const results = [];
107
+ for (const fixture of allFixtures) {
108
+ const result = await runFixture(scriptPath, fixture, { hookName });
109
+ results.push(result);
110
+ printResult(result);
111
+ }
112
+ return results;
113
+ }
114
+ function printResult(result) {
115
+ if (result.passed) {
116
+ const label = pc.green(" PASS");
117
+ const name = pc.bold(result.hookName);
118
+ console.log(`${label} ${name}: ${result.description}`);
119
+ } else {
120
+ const label = pc.red(" FAIL");
121
+ const name = pc.bold(result.hookName);
122
+ const detail = pc.dim(`(expected ${result.expectedExitCode}, got ${result.actualExitCode})`);
123
+ console.log(`${label} ${name}: ${result.description} ${detail}`);
124
+ }
125
+ }
126
+ function printSummary(summary) {
127
+ const { passed, failed } = summary;
128
+ const passStr = pc.green(`${passed} passed`);
129
+ const failStr = failed > 0 ? pc.red(`${failed} failed`) : pc.dim(`${failed} failed`);
130
+ console.log(`
131
+ ${passStr}, ${failStr}`);
132
+ }
133
+ async function testCommand(opts) {
134
+ const scope = opts.scope ?? "project";
135
+ const allResults = [];
136
+ if (opts.all) {
137
+ const userHooksDir = getHooksDir(scope);
138
+ const bundledHooks = listHooks();
139
+ const testedNames = /* @__PURE__ */ new Set();
140
+ for (const def of bundledHooks) {
141
+ const installedScript = join(userHooksDir, def.scriptFile);
142
+ if (existsSync(installedScript)) {
143
+ console.log(pc.bold(`
144
+ ${def.name}`));
145
+ const results = await testHook(def.name, scope);
146
+ allResults.push(...results);
147
+ testedNames.add(def.name);
148
+ }
149
+ }
150
+ const userFixturesRoot = join(userHooksDir, "fixtures");
151
+ if (existsSync(userFixturesRoot)) {
152
+ try {
153
+ const dirs = await readdir(userFixturesRoot, { withFileTypes: true });
154
+ for (const d of dirs) {
155
+ if (d.isDirectory() && !testedNames.has(d.name)) {
156
+ const userScript = join(userHooksDir, `${d.name}.sh`);
157
+ if (existsSync(userScript)) {
158
+ console.log(pc.bold(`
159
+ ${d.name}`) + pc.dim(" (custom)"));
160
+ const results = await testHook(d.name, scope);
161
+ allResults.push(...results);
162
+ }
163
+ }
164
+ }
165
+ } catch {
166
+ }
167
+ }
168
+ if (testedNames.size === 0 && allResults.length === 0) {
169
+ console.log(pc.yellow("\nNo installed hooks found. Run `claude-code-hookkit add <hook>` first."));
170
+ }
171
+ } else if (opts.hookName) {
172
+ console.log(pc.bold(`
173
+ ${opts.hookName}`));
174
+ const results = await testHook(opts.hookName, scope);
175
+ allResults.push(...results);
176
+ } else {
177
+ console.error(pc.red("Error: specify a hook name or use --all"));
178
+ process.exitCode = 1;
179
+ return { passed: 0, failed: 0, results: [] };
180
+ }
181
+ const passed = allResults.filter((r) => r.passed).length;
182
+ const failed = allResults.filter((r) => !r.passed).length;
183
+ const summary = { passed, failed, results: allResults };
184
+ printSummary(summary);
185
+ if (failed > 0) {
186
+ process.exitCode = 1;
187
+ }
188
+ return summary;
189
+ }
190
+ export {
191
+ discoverFixtures,
192
+ runFixture,
193
+ testCommand
194
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "claude-code-hookkit",
3
+ "version": "1.0.0",
4
+ "description": "Hook manager for Claude Code — install, manage, test, and share hooks with a single command",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-code-hookkit": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "registry"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "dev": "tsup --watch",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "typecheck": "tsc --noEmit",
22
+ "prepublishOnly": "npm run build && npm run typecheck && npm run test"
23
+ },
24
+ "author": "Austin Amelone",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/AmeloneGHS/claude-hooks.git"
28
+ },
29
+ "homepage": "https://github.com/AmeloneGHS/claude-hooks#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/AmeloneGHS/claude-hooks/issues"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^14.0.0",
35
+ "picocolors": "^1.1.1",
36
+ "zod": "^3.24.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.0.0",
40
+ "tsup": "^8.5.1",
41
+ "typescript": "^5.7.0",
42
+ "vitest": "^2.1.0"
43
+ },
44
+ "keywords": [
45
+ "claude",
46
+ "claude-code",
47
+ "hooks",
48
+ "cli",
49
+ "anthropic",
50
+ "developer-tools",
51
+ "automation",
52
+ "hook-manager",
53
+ "llm-tools",
54
+ "ai-safety",
55
+ "code-quality",
56
+ "security"
57
+ ],
58
+ "license": "MIT"
59
+ }
@@ -0,0 +1,44 @@
1
+ #!/bin/bash
2
+ # cost-tracker.sh
3
+ # PostToolUse hook (no matcher — runs for all tools)
4
+ #
5
+ # Appends a log entry for every tool call to a per-session log file.
6
+ # This creates a usage audit trail useful for understanding which tools
7
+ # Claude is calling most frequently and estimating session costs.
8
+ #
9
+ # Log file: /tmp/claude-code-hookkit-cost-<session_id>.log
10
+ # Log format: timestamp|tool_name
11
+ #
12
+ # Claude Code passes JSON via stdin. We extract tool_name and session_id.
13
+ #
14
+ # Exit codes:
15
+ # 0 - always allow (this hook never blocks)
16
+
17
+ INPUT=$(cat)
18
+
19
+ # Extract session_id from JSON using grep/sed (no jq or python3 required)
20
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"session_id"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
21
+
22
+ # Extract tool_name from JSON
23
+ TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"tool_name"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
24
+
25
+ # Fallbacks for missing fields
26
+ if [ -z "$SESSION_ID" ]; then
27
+ SESSION_ID="default"
28
+ fi
29
+
30
+ if [ -z "$TOOL_NAME" ]; then
31
+ TOOL_NAME="unknown"
32
+ fi
33
+
34
+ # Get current UTC timestamp in ISO 8601 format (POSIX date compatible)
35
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ")
36
+
37
+ # Log file path — unique per session
38
+ LOG_FILE="/tmp/claude-code-hookkit-cost-${SESSION_ID}.log"
39
+
40
+ # Append log entry: timestamp|tool_name
41
+ printf '%s|%s\n' "$TIMESTAMP" "$TOOL_NAME" >> "$LOG_FILE"
42
+
43
+ # Always exit 0 — this hook logs, it does not block
44
+ exit 0
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+ # error-advisor.sh
3
+ # PostToolUse hook for Bash events
4
+ #
5
+ # Analyzes the output of failed Bash commands and suggests contextual fixes.
6
+ # When Claude runs a command that fails, this hook examines the error output
7
+ # and prints actionable fix suggestions to stderr.
8
+ #
9
+ # Known error patterns and suggestions:
10
+ # EADDRINUSE -> Port already in use
11
+ # ENOENT -> File not found
12
+ # permission denied -> chmod/ownership issue
13
+ # MODULE_NOT_FOUND -> npm install needed
14
+ # ENOMEM -> Out of memory
15
+ # command not found -> Tool not installed
16
+ # ECONNREFUSED -> Service not running
17
+ # ETIMEDOUT -> Network timeout
18
+ # error TS[0-9]{4} -> TypeScript compilation error
19
+ #
20
+ # Claude Code PostToolUse JSON includes tool_response with command output.
21
+ # JSON is parsed with grep/sed (no jq or python3 required, bash 3.2+ compatible).
22
+ #
23
+ # Exit codes:
24
+ # 0 - always allow (advisory PostToolUse hook)
25
+
26
+ INPUT=$(cat)
27
+
28
+ # Extract tool_name to confirm this is a Bash tool call
29
+ TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"tool_name"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
30
+
31
+ # Only provide advice for Bash tool calls
32
+ if [ "$TOOL_NAME" != "Bash" ]; then
33
+ exit 0
34
+ fi
35
+
36
+ # Extract tool output / response (where error messages appear)
37
+ # Claude Code PostToolUse includes tool_response as a JSON field
38
+ TOOL_RESPONSE=$(printf '%s' "$INPUT" | grep -o '"tool_response"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"tool_response"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
39
+
40
+ # Also check stdout/stderr fields
41
+ STDOUT=$(printf '%s' "$INPUT" | grep -o '"stdout"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"stdout"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
42
+ STDERR_VAL=$(printf '%s' "$INPUT" | grep -o '"stderr"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/"stderr"[[:space:]]*:[[:space:]]*"\(.*\)"/\1/')
43
+
44
+ # Combine all output for pattern matching
45
+ ALL_OUTPUT="${TOOL_RESPONSE} ${STDOUT} ${STDERR_VAL}"
46
+
47
+ # If no output at all, nothing to advise on
48
+ if [ -z "$(printf '%s' "$ALL_OUTPUT" | tr -d '[:space:]')" ]; then
49
+ exit 0
50
+ fi
51
+
52
+ # --- Error pattern detection and advice ---
53
+ # Each pattern uses case-insensitive grep for robustness
54
+
55
+ # EADDRINUSE: Port already in use
56
+ # Common when starting a dev server when another process holds the port
57
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "EADDRINUSE"; then
58
+ PORT=$(printf '%s' "$ALL_OUTPUT" | grep -o 'EADDRINUSE.*[0-9][0-9][0-9][0-9]' | grep -o '[0-9][0-9][0-9][0-9]*' | head -1)
59
+ if [ -n "$PORT" ]; then
60
+ printf '[error-advisor] Port %s is already in use. Fix:\n lsof -ti:%s | xargs kill -9\n' "$PORT" "$PORT" >&2
61
+ else
62
+ printf '[error-advisor] A port is already in use. Fix:\n lsof -ti:<port> | xargs kill -9\n' >&2
63
+ fi
64
+ fi
65
+
66
+ # ENOENT: No such file or directory
67
+ # Common when referencing a path that doesn't exist yet
68
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "ENOENT\|No such file or directory"; then
69
+ printf '[error-advisor] File or directory not found. Fix:\n Check the path exists: ls -la <path>\n Create missing directories: mkdir -p <dir>\n' >&2
70
+ fi
71
+
72
+ # Permission denied: file permission issue
73
+ # Common when a script is not executable or user lacks write access
74
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "permission denied\|EACCES"; then
75
+ printf '[error-advisor] Permission denied. Fix:\n Make executable: chmod +x <file>\n Check ownership: ls -la <file>\n' >&2
76
+ fi
77
+
78
+ # MODULE_NOT_FOUND: Node.js module missing
79
+ # Common after checkout or when dependencies haven't been installed
80
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "MODULE_NOT_FOUND\|Cannot find module"; then
81
+ printf '[error-advisor] Node.js module not found. Fix:\n npm install\n # or: npm ci (for clean installs)\n' >&2
82
+ fi
83
+
84
+ # ENOMEM: Out of memory
85
+ # Node.js heap exhaustion or system memory pressure
86
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "ENOMEM\|out of memory\|JavaScript heap out of memory"; then
87
+ printf '[error-advisor] Out of memory. Fix:\n NODE_OPTIONS="--max-old-space-size=4096" <command>\n Close other applications to free memory\n' >&2
88
+ fi
89
+
90
+ # command not found: tool not installed
91
+ # Common when a CLI tool needs to be installed first
92
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "command not found"; then
93
+ printf '[error-advisor] A required command was not found. Install the missing tool via package manager.\n' >&2
94
+ fi
95
+
96
+ # ECONNREFUSED: Service not running
97
+ # Common when trying to connect to a server that hasn't started
98
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "ECONNREFUSED\|Connection refused"; then
99
+ printf '[error-advisor] Connection refused — the target service is not running. Fix:\n Check if service is running: ps aux | grep <service>\n Start the service first, then retry\n' >&2
100
+ fi
101
+
102
+ # ETIMEDOUT: Network timeout
103
+ # Common on slow networks or when a service is unreachable
104
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "ETIMEDOUT\|timed out\|timeout"; then
105
+ printf '[error-advisor] Network timeout. Fix:\n Check network connectivity: ping <host>\n The service may be temporarily unavailable — retry in a moment\n' >&2
106
+ fi
107
+
108
+ # TypeScript compilation errors
109
+ if printf '%s' "$ALL_OUTPUT" | grep -qi "error TS[0-9]"; then
110
+ printf '[error-advisor] TypeScript compilation error detected. Fix:\n Run: npx tsc --noEmit to see all errors\n Check type annotations and interface definitions\n' >&2
111
+ fi
112
+
113
+ # Always exit 0 — this hook advises, it does not block
114
+ exit 0