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,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/registry/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
|
|
8
|
+
// src/registry/types.ts
|
|
9
|
+
import { z as z2 } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/types/hooks.ts
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
var hookEventSchema = z.enum([
|
|
14
|
+
"PreToolUse",
|
|
15
|
+
"PostToolUse",
|
|
16
|
+
"PostToolUseFailure",
|
|
17
|
+
"PermissionRequest",
|
|
18
|
+
"SessionStart",
|
|
19
|
+
"Stop",
|
|
20
|
+
"StopFailure",
|
|
21
|
+
"SessionEnd",
|
|
22
|
+
"Notification",
|
|
23
|
+
"SubagentStart",
|
|
24
|
+
"SubagentStop",
|
|
25
|
+
"UserPromptSubmit",
|
|
26
|
+
"ConfigChange",
|
|
27
|
+
"PreCompact",
|
|
28
|
+
"PostCompact",
|
|
29
|
+
"WorktreeCreate",
|
|
30
|
+
"WorktreeRemove",
|
|
31
|
+
"TeammateIdle",
|
|
32
|
+
"TaskCompleted",
|
|
33
|
+
"InstructionsLoaded",
|
|
34
|
+
"Elicitation",
|
|
35
|
+
"ElicitationResult"
|
|
36
|
+
]);
|
|
37
|
+
var hookEntrySchema = z.object({
|
|
38
|
+
type: z.literal("command"),
|
|
39
|
+
command: z.string().min(1)
|
|
40
|
+
});
|
|
41
|
+
var hookGroupSchema = z.object({
|
|
42
|
+
matcher: z.string().optional(),
|
|
43
|
+
hooks: z.array(hookEntrySchema)
|
|
44
|
+
});
|
|
45
|
+
var claudeSettingsSchema = z.object({
|
|
46
|
+
env: z.record(z.string()).optional(),
|
|
47
|
+
hooks: z.record(z.array(hookGroupSchema)).optional(),
|
|
48
|
+
mcpServers: z.record(z.unknown()).optional(),
|
|
49
|
+
enabledPlugins: z.record(z.boolean()).optional(),
|
|
50
|
+
statusLine: z.unknown().optional()
|
|
51
|
+
}).passthrough();
|
|
52
|
+
|
|
53
|
+
// src/registry/types.ts
|
|
54
|
+
var hookDefinitionSchema = z2.object({
|
|
55
|
+
name: z2.string().min(1),
|
|
56
|
+
description: z2.string().min(1),
|
|
57
|
+
event: hookEventSchema,
|
|
58
|
+
matcher: z2.string().optional(),
|
|
59
|
+
pack: z2.string().optional(),
|
|
60
|
+
scriptFile: z2.string().min(1)
|
|
61
|
+
});
|
|
62
|
+
var hookPackSchema = z2.object({
|
|
63
|
+
name: z2.string().min(1),
|
|
64
|
+
description: z2.string().min(1),
|
|
65
|
+
hooks: z2.array(z2.string())
|
|
66
|
+
});
|
|
67
|
+
var registrySchema = z2.object({
|
|
68
|
+
hooks: z2.record(hookDefinitionSchema),
|
|
69
|
+
packs: z2.record(hookPackSchema)
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// src/registry/index.ts
|
|
73
|
+
import { existsSync } from "fs";
|
|
74
|
+
var _require = createRequire(import.meta.url);
|
|
75
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
76
|
+
var __dirname = dirname(__filename);
|
|
77
|
+
function findRegistryPath() {
|
|
78
|
+
let dir = __dirname;
|
|
79
|
+
for (let i = 0; i < 4; i++) {
|
|
80
|
+
const candidate = join(dir, "registry", "registry.json");
|
|
81
|
+
if (existsSync(candidate)) return candidate;
|
|
82
|
+
const parent = join(dir, "..");
|
|
83
|
+
if (parent === dir) break;
|
|
84
|
+
dir = parent;
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`registry/registry.json not found searching up from: ${__dirname}`);
|
|
87
|
+
}
|
|
88
|
+
var _cache = null;
|
|
89
|
+
function loadRegistry() {
|
|
90
|
+
if (_cache !== null) {
|
|
91
|
+
return _cache;
|
|
92
|
+
}
|
|
93
|
+
const registryPath = findRegistryPath();
|
|
94
|
+
const raw = _require(registryPath);
|
|
95
|
+
const parsed = registrySchema.parse(raw);
|
|
96
|
+
_cache = parsed;
|
|
97
|
+
return _cache;
|
|
98
|
+
}
|
|
99
|
+
function getHook(name) {
|
|
100
|
+
return loadRegistry().hooks[name];
|
|
101
|
+
}
|
|
102
|
+
function getPack(name) {
|
|
103
|
+
return loadRegistry().packs[name];
|
|
104
|
+
}
|
|
105
|
+
function listHooks() {
|
|
106
|
+
return Object.values(loadRegistry().hooks);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
getHook,
|
|
111
|
+
getPack,
|
|
112
|
+
listHooks
|
|
113
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
var program = new Command().name("claude-code-hookkit").description("Hook manager for Claude Code \u2014 install, manage, test, and share hooks").version("1.0.0");
|
|
6
|
+
program.command("init").description("Initialize claude-code-hookkit in your project").option("--scope <scope>", "Settings scope: user, project, or local", "project").option("--dry-run", "Preview changes without writing").addHelpText("after", `
|
|
7
|
+
Examples:
|
|
8
|
+
$ claude-code-hookkit init
|
|
9
|
+
$ claude-code-hookkit init --scope user
|
|
10
|
+
$ claude-code-hookkit init --dry-run`).action(async (opts) => {
|
|
11
|
+
const { initCommand } = await import("./init-RHEFGGUF.js");
|
|
12
|
+
await initCommand(opts);
|
|
13
|
+
});
|
|
14
|
+
program.command("restore").description("Restore settings.json from the last backup").option("--scope <scope>", "Settings scope: user, project, or local", "project").addHelpText("after", `
|
|
15
|
+
Examples:
|
|
16
|
+
$ claude-code-hookkit restore
|
|
17
|
+
$ claude-code-hookkit restore --scope user`).action(async (opts) => {
|
|
18
|
+
const { restoreCommand } = await import("./restore-7JQ3CHWZ.js");
|
|
19
|
+
await restoreCommand(opts);
|
|
20
|
+
});
|
|
21
|
+
program.command("add <name>").description("Install a hook from the registry into your project").option("--scope <scope>", "Settings scope: user, project, or local", "project").option("--dry-run", "Preview changes without writing").addHelpText("after", `
|
|
22
|
+
Examples:
|
|
23
|
+
$ claude-code-hookkit add sensitive-path-guard
|
|
24
|
+
$ claude-code-hookkit add security-pack
|
|
25
|
+
$ claude-code-hookkit add cost-tracker --scope user
|
|
26
|
+
$ claude-code-hookkit add post-edit-lint --dry-run`).action(async (name, opts) => {
|
|
27
|
+
const { addCommand } = await import("./add-O4OSFQ76.js");
|
|
28
|
+
await addCommand({ ...opts, hookName: name });
|
|
29
|
+
});
|
|
30
|
+
program.command("remove <name>").description("Remove an installed hook from your project").option("--scope <scope>", "Settings scope: user, project, or local", "project").option("--dry-run", "Preview changes without writing").addHelpText("after", `
|
|
31
|
+
Examples:
|
|
32
|
+
$ claude-code-hookkit remove sensitive-path-guard
|
|
33
|
+
$ claude-code-hookkit remove post-edit-lint --scope user
|
|
34
|
+
$ claude-code-hookkit remove web-budget-gate --dry-run`).action(async (name, opts) => {
|
|
35
|
+
const { removeCommand } = await import("./remove-Z5QIW45P.js");
|
|
36
|
+
await removeCommand({ ...opts, hookName: name });
|
|
37
|
+
});
|
|
38
|
+
program.command("list").description("List all available hooks with installed status").option("--scope <scope>", "Settings scope: user, project, or local", "project").addHelpText("after", `
|
|
39
|
+
Examples:
|
|
40
|
+
$ claude-code-hookkit list
|
|
41
|
+
$ claude-code-hookkit list --scope user`).action(async (opts) => {
|
|
42
|
+
const { listCommand } = await import("./list-SCSGYOBR.js");
|
|
43
|
+
await listCommand(opts);
|
|
44
|
+
});
|
|
45
|
+
program.command("doctor").description("Validate hook installation health").option("--scope <scope>", "Settings scope: user, project, or local", "project").addHelpText("after", `
|
|
46
|
+
Examples:
|
|
47
|
+
$ claude-code-hookkit doctor
|
|
48
|
+
$ claude-code-hookkit doctor --scope user
|
|
49
|
+
$ claude-code-hookkit doctor --scope project`).action(async (opts) => {
|
|
50
|
+
const { doctorCommand } = await import("./doctor-UBK2C2TW.js");
|
|
51
|
+
await doctorCommand(opts);
|
|
52
|
+
});
|
|
53
|
+
program.command("test [hook]").description("Test hooks with fixture data").option("--all", "Test all installed hooks").option("--scope <scope>", "Settings scope: user, project, or local", "project").addHelpText("after", `
|
|
54
|
+
Examples:
|
|
55
|
+
$ claude-code-hookkit test sensitive-path-guard
|
|
56
|
+
$ claude-code-hookkit test --all`).action(async (hook, opts) => {
|
|
57
|
+
const { testCommand } = await import("./test-ZRRLZ62R.js");
|
|
58
|
+
await testCommand({ hookName: hook, ...opts });
|
|
59
|
+
});
|
|
60
|
+
program.command("create <name>").description("Scaffold a new custom hook from a template").requiredOption("--event <type>", "Hook event type (PreToolUse, PostToolUse, SessionStart, Stop)").option("--matcher <pattern>", 'Tool matcher pattern (e.g., "Bash", "Edit|Write")').option("--scope <scope>", "Settings scope: user, project, or local", "project").addHelpText("after", `
|
|
61
|
+
Examples:
|
|
62
|
+
$ claude-code-hookkit create my-guard --event PreToolUse --matcher Bash
|
|
63
|
+
$ claude-code-hookkit create session-logger --event SessionStart
|
|
64
|
+
$ claude-code-hookkit create cleanup --event Stop`).action(async (name, opts) => {
|
|
65
|
+
const { createCommand } = await import("./create-DBLA6PTS.js");
|
|
66
|
+
await createCommand({ name, ...opts });
|
|
67
|
+
});
|
|
68
|
+
program.command("info <hook>").description("Show details for a hook: description, event, matcher, pack, and examples").addHelpText("after", `
|
|
69
|
+
Examples:
|
|
70
|
+
$ claude-code-hookkit info sensitive-path-guard
|
|
71
|
+
$ claude-code-hookkit info web-budget-gate
|
|
72
|
+
$ claude-code-hookkit info error-advisor`).action(async (hook) => {
|
|
73
|
+
const { infoCommand } = await import("./info-FLYMAHDX.js");
|
|
74
|
+
await infoCommand({ hookName: hook });
|
|
75
|
+
});
|
|
76
|
+
program.parse();
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
log
|
|
4
|
+
} from "./chunk-2BZZUQQ3.js";
|
|
5
|
+
import {
|
|
6
|
+
getHooksDir
|
|
7
|
+
} from "./chunk-PEDGREZY.js";
|
|
8
|
+
|
|
9
|
+
// src/commands/create.ts
|
|
10
|
+
import { mkdirSync, writeFileSync, chmodSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
// src/templates/index.ts
|
|
14
|
+
var SUPPORTED_EVENTS = ["PreToolUse", "PostToolUse", "SessionStart", "Stop"];
|
|
15
|
+
function listTemplateEvents() {
|
|
16
|
+
return [...SUPPORTED_EVENTS];
|
|
17
|
+
}
|
|
18
|
+
function getTemplate(event, name, matcher) {
|
|
19
|
+
switch (event) {
|
|
20
|
+
case "PreToolUse":
|
|
21
|
+
return preToolUseTemplate(name, matcher);
|
|
22
|
+
case "PostToolUse":
|
|
23
|
+
return postToolUseTemplate(name, matcher);
|
|
24
|
+
case "SessionStart":
|
|
25
|
+
return sessionStartTemplate(name);
|
|
26
|
+
case "Stop":
|
|
27
|
+
return stopTemplate(name);
|
|
28
|
+
default:
|
|
29
|
+
return genericTemplate(name, event);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function preToolUseTemplate(name, matcher) {
|
|
33
|
+
const matcherLine = matcher ? `# Matcher: ${matcher}` : "# Matcher: (all tools)";
|
|
34
|
+
return `#!/usr/bin/env bash
|
|
35
|
+
# ${name} \u2014 PreToolUse hook
|
|
36
|
+
${matcherLine}
|
|
37
|
+
# Created by claude-code-hookkit create
|
|
38
|
+
#
|
|
39
|
+
# PreToolUse hooks can block tool execution:
|
|
40
|
+
# exit 0 \u2192 allow the tool to run
|
|
41
|
+
# exit 2 \u2192 block the tool (stderr is shown to Claude as feedback)
|
|
42
|
+
set -euo pipefail
|
|
43
|
+
|
|
44
|
+
# Read JSON input from stdin
|
|
45
|
+
INPUT=$(cat)
|
|
46
|
+
|
|
47
|
+
# Extract fields using grep (pure bash, no external JSON parser required)
|
|
48
|
+
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
49
|
+
FILE_PATH=$(echo "$INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
50
|
+
COMMAND=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
51
|
+
|
|
52
|
+
# TODO: Add your blocking logic here
|
|
53
|
+
# Example: block if file_path matches a sensitive pattern
|
|
54
|
+
# if echo "$FILE_PATH" | grep -qE '\\.env|\\.secret|credentials'; then
|
|
55
|
+
# echo "BLOCKED: Sensitive file access denied" >&2
|
|
56
|
+
# exit 2
|
|
57
|
+
# fi
|
|
58
|
+
|
|
59
|
+
# Allow by default
|
|
60
|
+
exit 0
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
function postToolUseTemplate(name, matcher) {
|
|
64
|
+
const matcherLine = matcher ? `# Matcher: ${matcher}` : "# Matcher: (all tools)";
|
|
65
|
+
return `#!/usr/bin/env bash
|
|
66
|
+
# ${name} \u2014 PostToolUse hook
|
|
67
|
+
${matcherLine}
|
|
68
|
+
# Created by claude-code-hookkit create
|
|
69
|
+
#
|
|
70
|
+
# PostToolUse hooks are informational \u2014 they should always exit 0.
|
|
71
|
+
# They run after the tool has already executed.
|
|
72
|
+
set -euo pipefail
|
|
73
|
+
|
|
74
|
+
# Read JSON input from stdin
|
|
75
|
+
INPUT=$(cat)
|
|
76
|
+
|
|
77
|
+
# Extract fields using grep (pure bash, no external JSON parser required, bash 3.2+ compatible)
|
|
78
|
+
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
79
|
+
TOOL_RESPONSE=$(echo "$INPUT" | grep -o '"tool_response":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
80
|
+
|
|
81
|
+
# TODO: Add your post-tool logic here (informational only)
|
|
82
|
+
# Example: log tool invocations, trigger notifications, update metrics
|
|
83
|
+
# echo "[$TOOL_NAME] completed" >> /tmp/tool-log.txt
|
|
84
|
+
|
|
85
|
+
# PostToolUse hooks should always exit 0
|
|
86
|
+
exit 0
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
function sessionStartTemplate(name) {
|
|
90
|
+
return `#!/usr/bin/env bash
|
|
91
|
+
# ${name} \u2014 SessionStart hook
|
|
92
|
+
# Created by claude-code-hookkit create
|
|
93
|
+
#
|
|
94
|
+
# SessionStart hooks run when a new Claude Code session begins.
|
|
95
|
+
set -euo pipefail
|
|
96
|
+
|
|
97
|
+
# Read JSON input from stdin
|
|
98
|
+
INPUT=$(cat)
|
|
99
|
+
|
|
100
|
+
# Extract fields using grep (pure bash, no external JSON parser required, bash 3.2+ compatible)
|
|
101
|
+
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
102
|
+
CWD=$(echo "$INPUT" | grep -o '"cwd":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
103
|
+
|
|
104
|
+
# TODO: Add your session initialization logic here
|
|
105
|
+
# Example: log session start, set up environment, notify external services
|
|
106
|
+
# echo "[$SESSION_ID] Session started in $CWD" >> /tmp/session-log.txt
|
|
107
|
+
|
|
108
|
+
exit 0
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
function stopTemplate(name) {
|
|
112
|
+
return `#!/usr/bin/env bash
|
|
113
|
+
# ${name} \u2014 Stop hook
|
|
114
|
+
# Created by claude-code-hookkit create
|
|
115
|
+
#
|
|
116
|
+
# Stop hooks run when a Claude Code session ends.
|
|
117
|
+
set -euo pipefail
|
|
118
|
+
|
|
119
|
+
# Read JSON input from stdin
|
|
120
|
+
INPUT=$(cat)
|
|
121
|
+
|
|
122
|
+
# Extract fields using grep (pure bash, no external JSON parser required, bash 3.2+ compatible)
|
|
123
|
+
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
124
|
+
|
|
125
|
+
# TODO: Add your cleanup/summary logic here
|
|
126
|
+
# Example: summarize session, flush logs, send notifications
|
|
127
|
+
# echo "[$SESSION_ID] Session ended" >> /tmp/session-log.txt
|
|
128
|
+
|
|
129
|
+
exit 0
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
function genericTemplate(name, event) {
|
|
133
|
+
return `#!/usr/bin/env bash
|
|
134
|
+
# ${name} \u2014 ${event} hook
|
|
135
|
+
# Created by claude-code-hookkit create
|
|
136
|
+
set -euo pipefail
|
|
137
|
+
|
|
138
|
+
# Read JSON input from stdin
|
|
139
|
+
INPUT=$(cat)
|
|
140
|
+
|
|
141
|
+
# Extract session_id using grep (pure bash, no external JSON parser required, bash 3.2+ compatible)
|
|
142
|
+
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
143
|
+
|
|
144
|
+
# TODO: Add your hook logic here
|
|
145
|
+
|
|
146
|
+
exit 0
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/commands/create.ts
|
|
151
|
+
function buildFixtures(event) {
|
|
152
|
+
switch (event) {
|
|
153
|
+
case "PreToolUse":
|
|
154
|
+
return {
|
|
155
|
+
allow: {
|
|
156
|
+
description: "Example: allows safe input",
|
|
157
|
+
input: { tool_input: { command: "echo hello" } },
|
|
158
|
+
expectedExitCode: 0
|
|
159
|
+
},
|
|
160
|
+
block: {
|
|
161
|
+
description: "Example: blocks dangerous input",
|
|
162
|
+
input: { tool_input: { command: "REPLACE_ME" } },
|
|
163
|
+
expectedExitCode: 2
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
case "PostToolUse":
|
|
167
|
+
return {
|
|
168
|
+
allow: {
|
|
169
|
+
description: "Example: processes tool output",
|
|
170
|
+
input: { tool_name: "Bash", tool_response: "output here" },
|
|
171
|
+
expectedExitCode: 0
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
case "SessionStart":
|
|
175
|
+
return {
|
|
176
|
+
allow: {
|
|
177
|
+
description: "Example: session initialization",
|
|
178
|
+
input: { session_id: "test-session", cwd: "/tmp" },
|
|
179
|
+
expectedExitCode: 0
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
case "Stop":
|
|
183
|
+
return {
|
|
184
|
+
allow: {
|
|
185
|
+
description: "Example: session cleanup",
|
|
186
|
+
input: { session_id: "test-session" },
|
|
187
|
+
expectedExitCode: 0
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
default:
|
|
191
|
+
return {
|
|
192
|
+
allow: {
|
|
193
|
+
description: "Example: allow input",
|
|
194
|
+
input: { session_id: "test-session" },
|
|
195
|
+
expectedExitCode: 0
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function _createAt(opts) {
|
|
201
|
+
const { name, event, matcher, hooksDir } = opts;
|
|
202
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
203
|
+
return { invalidName: true };
|
|
204
|
+
}
|
|
205
|
+
const supportedEvents = listTemplateEvents();
|
|
206
|
+
if (!supportedEvents.includes(event)) {
|
|
207
|
+
return { invalidEvent: true };
|
|
208
|
+
}
|
|
209
|
+
const scriptPath = join(hooksDir, `${name}.sh`);
|
|
210
|
+
const fixturesDir = join(hooksDir, "fixtures", name);
|
|
211
|
+
if (existsSync(scriptPath)) {
|
|
212
|
+
return { skipped: true };
|
|
213
|
+
}
|
|
214
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
215
|
+
mkdirSync(fixturesDir, { recursive: true });
|
|
216
|
+
const template = getTemplate(event, name, matcher);
|
|
217
|
+
writeFileSync(scriptPath, template, "utf8");
|
|
218
|
+
chmodSync(scriptPath, 493);
|
|
219
|
+
const fixtures = buildFixtures(event);
|
|
220
|
+
writeFileSync(
|
|
221
|
+
join(fixturesDir, "allow-example.json"),
|
|
222
|
+
JSON.stringify(fixtures.allow, null, 2) + "\n",
|
|
223
|
+
"utf8"
|
|
224
|
+
);
|
|
225
|
+
if (fixtures.block) {
|
|
226
|
+
writeFileSync(
|
|
227
|
+
join(fixturesDir, "block-example.json"),
|
|
228
|
+
JSON.stringify(fixtures.block, null, 2) + "\n",
|
|
229
|
+
"utf8"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return { created: true, scriptPath, fixturesDir };
|
|
233
|
+
}
|
|
234
|
+
async function createCommand(opts) {
|
|
235
|
+
const { name, event, matcher, scope = "project" } = opts;
|
|
236
|
+
const hooksDir = getHooksDir(scope);
|
|
237
|
+
const result = await _createAt({ name, event, matcher, hooksDir });
|
|
238
|
+
if (result.invalidName) {
|
|
239
|
+
log.error(
|
|
240
|
+
`Invalid hook name: "${name}". Names must be lowercase alphanumeric with hyphens (e.g., my-guard).`
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (result.invalidEvent) {
|
|
245
|
+
const supported = listTemplateEvents().join(", ");
|
|
246
|
+
log.error(`Unsupported event type: "${event}". Supported types: ${supported}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (result.skipped) {
|
|
250
|
+
log.warn(`Hook already exists: ${join(hooksDir, name + ".sh")}. Remove it first to recreate.`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (result.created) {
|
|
254
|
+
log.success(`Created hook: ${result.scriptPath}`);
|
|
255
|
+
log.dim(` Fixtures: ${result.fixturesDir}/allow-example.json`);
|
|
256
|
+
if (event === "PreToolUse") {
|
|
257
|
+
log.dim(` Fixtures: ${result.fixturesDir}/block-example.json`);
|
|
258
|
+
}
|
|
259
|
+
log.info("");
|
|
260
|
+
log.info("Next steps:");
|
|
261
|
+
log.dim(` 1. Edit ${result.scriptPath} to add your logic`);
|
|
262
|
+
log.dim(` 2. Run: claude-code-hookkit test ${name}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
_createAt,
|
|
267
|
+
createCommand
|
|
268
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readSettings
|
|
4
|
+
} from "./chunk-LRXKKJDU.js";
|
|
5
|
+
import "./chunk-QKT647BI.js";
|
|
6
|
+
import {
|
|
7
|
+
getHooksDir,
|
|
8
|
+
getSettingsPath
|
|
9
|
+
} from "./chunk-PEDGREZY.js";
|
|
10
|
+
|
|
11
|
+
// src/commands/doctor.ts
|
|
12
|
+
import { existsSync, accessSync, constants } from "fs";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
function extractCommands(hooks) {
|
|
15
|
+
const result = [];
|
|
16
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
17
|
+
if (!Array.isArray(groups)) continue;
|
|
18
|
+
for (const group of groups) {
|
|
19
|
+
if (!group.hooks || !Array.isArray(group.hooks)) continue;
|
|
20
|
+
for (const entry of group.hooks) {
|
|
21
|
+
if (entry.type === "command" && entry.command) {
|
|
22
|
+
result.push({ event, matcher: group.matcher, command: entry.command });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
async function _doctorAt(opts) {
|
|
30
|
+
const { settingsPath, hooksDir } = opts;
|
|
31
|
+
const checks = [];
|
|
32
|
+
let settings;
|
|
33
|
+
if (!existsSync(settingsPath)) {
|
|
34
|
+
checks.push({ level: "warn", message: `Settings file not found: ${settingsPath}` });
|
|
35
|
+
} else {
|
|
36
|
+
try {
|
|
37
|
+
settings = await readSettings(settingsPath);
|
|
38
|
+
checks.push({ level: "pass", message: "Settings file valid" });
|
|
39
|
+
} catch {
|
|
40
|
+
checks.push({ level: "fail", message: `Settings file parse error: malformed JSON at ${settingsPath}` });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(hooksDir)) {
|
|
44
|
+
checks.push({ level: "fail", message: `Hooks dir not found: ${hooksDir}` });
|
|
45
|
+
} else {
|
|
46
|
+
checks.push({ level: "pass", message: "Hooks directory exists" });
|
|
47
|
+
}
|
|
48
|
+
if (settings !== void 0) {
|
|
49
|
+
const hookDefs = settings.hooks;
|
|
50
|
+
const allEntries = hookDefs ? extractCommands(hookDefs) : [];
|
|
51
|
+
if (allEntries.length === 0) {
|
|
52
|
+
checks.push({ level: "pass", message: "No hook scripts to validate" });
|
|
53
|
+
} else {
|
|
54
|
+
for (const { command: scriptPath } of allEntries) {
|
|
55
|
+
if (!existsSync(scriptPath)) {
|
|
56
|
+
checks.push({ level: "fail", message: `Script not found: ${scriptPath}` });
|
|
57
|
+
} else {
|
|
58
|
+
try {
|
|
59
|
+
accessSync(scriptPath, constants.X_OK);
|
|
60
|
+
checks.push({ level: "pass", message: `Script OK: ${scriptPath}` });
|
|
61
|
+
} catch {
|
|
62
|
+
checks.push({ level: "fail", message: `Script not executable: ${scriptPath}` });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (hookDefs) {
|
|
68
|
+
const tupleMap = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const { event, matcher, command } of allEntries) {
|
|
70
|
+
const key = `${event}::${matcher ?? "__none__"}`;
|
|
71
|
+
if (!tupleMap.has(key)) {
|
|
72
|
+
tupleMap.set(key, /* @__PURE__ */ new Set());
|
|
73
|
+
}
|
|
74
|
+
tupleMap.get(key).add(command);
|
|
75
|
+
}
|
|
76
|
+
let conflictFound = false;
|
|
77
|
+
for (const [key, commands] of tupleMap.entries()) {
|
|
78
|
+
if (commands.size > 1) {
|
|
79
|
+
const parts = key.split("::");
|
|
80
|
+
const event = parts[0];
|
|
81
|
+
const matcher = parts[1];
|
|
82
|
+
const label = matcher === "__none__" ? event : `${event}/${matcher}`;
|
|
83
|
+
checks.push({
|
|
84
|
+
level: "warn",
|
|
85
|
+
message: `Conflicting hooks: ${label} has ${commands.size} commands`
|
|
86
|
+
});
|
|
87
|
+
conflictFound = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!conflictFound) {
|
|
91
|
+
checks.push({ level: "pass", message: "No conflicting hooks" });
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
checks.push({ level: "pass", message: "No conflicting hooks" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const passed = checks.filter((c) => c.level === "pass").length;
|
|
98
|
+
const failed = checks.filter((c) => c.level === "fail").length;
|
|
99
|
+
const warnings = checks.filter((c) => c.level === "warn").length;
|
|
100
|
+
const exitCode = failed > 0 ? 1 : 0;
|
|
101
|
+
return { checks, passed, failed, warnings, exitCode };
|
|
102
|
+
}
|
|
103
|
+
function printResults(result) {
|
|
104
|
+
console.log("");
|
|
105
|
+
for (const check of result.checks) {
|
|
106
|
+
if (check.level === "pass") {
|
|
107
|
+
console.log(pc.green("[PASS]") + " " + check.message);
|
|
108
|
+
} else if (check.level === "fail") {
|
|
109
|
+
console.log(pc.red("[FAIL]") + " " + check.message);
|
|
110
|
+
} else {
|
|
111
|
+
console.log(pc.yellow("[WARN]") + " " + check.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
console.log("");
|
|
115
|
+
const summary = `${result.passed} passed, ${result.failed} failed, ${result.warnings} warning${result.warnings !== 1 ? "s" : ""}`;
|
|
116
|
+
if (result.failed > 0) {
|
|
117
|
+
console.log(pc.red(summary));
|
|
118
|
+
} else if (result.warnings > 0) {
|
|
119
|
+
console.log(pc.yellow(summary));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(pc.green(summary));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function doctorCommand(opts) {
|
|
125
|
+
const validScopes = ["user", "project", "local"];
|
|
126
|
+
const scope = validScopes.includes(opts.scope) ? opts.scope : "project";
|
|
127
|
+
const result = await _doctorAt({
|
|
128
|
+
settingsPath: getSettingsPath(scope),
|
|
129
|
+
hooksDir: getHooksDir(scope)
|
|
130
|
+
});
|
|
131
|
+
printResults(result);
|
|
132
|
+
process.exit(result.exitCode);
|
|
133
|
+
}
|
|
134
|
+
export {
|
|
135
|
+
_doctorAt,
|
|
136
|
+
doctorCommand
|
|
137
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getHook
|
|
4
|
+
} from "./chunk-XLX5K6TZ.js";
|
|
5
|
+
|
|
6
|
+
// src/commands/info.ts
|
|
7
|
+
import { readdir, readFile } from "fs/promises";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
var __dirname = dirname(__filename);
|
|
14
|
+
function fixturesDir(hookName) {
|
|
15
|
+
let dir = __dirname;
|
|
16
|
+
for (let i = 0; i < 4; i++) {
|
|
17
|
+
const candidate = join(dir, "registry", "hooks", "fixtures", hookName);
|
|
18
|
+
if (existsSync(candidate)) return candidate;
|
|
19
|
+
const parent = join(dir, "..");
|
|
20
|
+
if (parent === dir) break;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
return join(__dirname, "..", "registry", "hooks", "fixtures", hookName);
|
|
24
|
+
}
|
|
25
|
+
async function loadFixtures(hookName) {
|
|
26
|
+
const dir = fixturesDir(hookName);
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
let files;
|
|
29
|
+
try {
|
|
30
|
+
files = await readdir(dir);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
|
|
35
|
+
const fixtures = [];
|
|
36
|
+
for (const file of jsonFiles) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(join(dir, file), "utf8");
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
fixtures.push(parsed);
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return fixtures;
|
|
45
|
+
}
|
|
46
|
+
async function infoCommand(opts) {
|
|
47
|
+
const { hookName } = opts;
|
|
48
|
+
const hook = getHook(hookName);
|
|
49
|
+
if (!hook) {
|
|
50
|
+
console.error(pc.red(`Hook "${hookName}" not found in registry.`));
|
|
51
|
+
console.error(pc.dim("Run `claude-code-hookkit list` to see all available hooks."));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
console.log("");
|
|
55
|
+
console.log(pc.bold(pc.cyan(`${hook.name}`)));
|
|
56
|
+
console.log(pc.dim("\u2500".repeat(50)));
|
|
57
|
+
console.log(`${pc.bold("Description:")} ${hook.description}`);
|
|
58
|
+
console.log(`${pc.bold("Event:")} ${pc.yellow(hook.event)}`);
|
|
59
|
+
console.log(`${pc.bold("Matcher:")} ${hook.matcher ?? pc.dim("(all tools)")}`);
|
|
60
|
+
console.log(`${pc.bold("Pack:")} ${pc.cyan(hook.pack ?? pc.dim("(standalone)"))}`);
|
|
61
|
+
console.log(`${pc.bold("Script:")} ${hook.scriptFile}`);
|
|
62
|
+
const fixtures = await loadFixtures(hookName);
|
|
63
|
+
if (fixtures.length === 0) {
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(pc.dim("No example fixtures found."));
|
|
66
|
+
} else {
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(pc.bold("Examples:"));
|
|
69
|
+
console.log("");
|
|
70
|
+
for (const fixture of fixtures) {
|
|
71
|
+
const exitLabel = fixture.expectedExitCode === 0 ? pc.green(`exit ${fixture.expectedExitCode} (allow)`) : pc.red(`exit ${fixture.expectedExitCode} (block)`);
|
|
72
|
+
console.log(` ${pc.bold(fixture.description)}`);
|
|
73
|
+
console.log(` ${pc.dim("Input:")} ${JSON.stringify(fixture.input)}`);
|
|
74
|
+
console.log(` ${pc.dim("Outcome:")} ${exitLabel}`);
|
|
75
|
+
console.log("");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
console.log(pc.dim(`Install with: claude-code-hookkit add ${hookName}`));
|
|
79
|
+
console.log(pc.dim(`Test with: claude-code-hookkit test ${hookName}`));
|
|
80
|
+
console.log("");
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
infoCommand
|
|
84
|
+
};
|