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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/add-O4OSFQ76.js +140 -0
- package/dist/chunk-2BZZUQQ3.js +34 -0
- package/dist/chunk-LRXKKJDU.js +101 -0
- package/dist/chunk-PEDGREZY.js +46 -0
- package/dist/chunk-QKT647BI.js +30 -0
- package/dist/chunk-XLX5K6TZ.js +113 -0
- package/dist/cli.js +76 -0
- package/dist/create-DBLA6PTS.js +268 -0
- package/dist/doctor-UBK2C2TW.js +137 -0
- package/dist/info-FLYMAHDX.js +84 -0
- package/dist/init-RHEFGGUF.js +70 -0
- package/dist/list-SCSGYOBR.js +54 -0
- package/dist/remove-Z5QIW45P.js +109 -0
- package/dist/restore-7JQ3CHWZ.js +31 -0
- package/dist/test-ZRRLZ62R.js +194 -0
- package/package.json +59 -0
- package/registry/hooks/cost-tracker.sh +44 -0
- package/registry/hooks/error-advisor.sh +114 -0
- package/registry/hooks/exit-code-enforcer.sh +76 -0
- package/registry/hooks/fixtures/cost-tracker/allow-bash-tool.json +5 -0
- package/registry/hooks/fixtures/cost-tracker/allow-no-session.json +5 -0
- package/registry/hooks/fixtures/error-advisor/allow-enoent.json +5 -0
- package/registry/hooks/fixtures/error-advisor/allow-no-error.json +5 -0
- package/registry/hooks/fixtures/exit-code-enforcer/allow-npm-test.json +5 -0
- package/registry/hooks/fixtures/exit-code-enforcer/block-rm-rf.json +5 -0
- package/registry/hooks/fixtures/post-edit-lint/allow-ts-file.json +5 -0
- package/registry/hooks/fixtures/post-edit-lint/allow-unknown-ext.json +5 -0
- package/registry/hooks/fixtures/sensitive-path-guard/allow-src.json +5 -0
- package/registry/hooks/fixtures/sensitive-path-guard/block-env.json +5 -0
- package/registry/hooks/fixtures/ts-check/allow-non-ts.json +5 -0
- package/registry/hooks/fixtures/ts-check/allow-ts-file.json +5 -0
- package/registry/hooks/fixtures/web-budget-gate/allow-within-budget.json +6 -0
- package/registry/hooks/fixtures/web-budget-gate/block-over-budget.json +6 -0
- package/registry/hooks/post-edit-lint.sh +82 -0
- package/registry/hooks/sensitive-path-guard.sh +103 -0
- package/registry/hooks/ts-check.sh +98 -0
- package/registry/hooks/web-budget-gate.sh +60 -0
- 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
|