@goodfoot/claude-code-hooks 1.0.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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Scaffold module for generating new Claude Code hook projects.
3
+ *
4
+ * Generates a complete TypeScript project structure with:
5
+ * - package.json with dependencies and scripts
6
+ * - tsconfig.json with ESM/Node20 configuration
7
+ * - biome.json for linting/formatting
8
+ * - Hook template files for each requested hook type
9
+ * - Vitest test files for each hook
10
+ * - vitest.config.ts for test configuration
11
+ * @module
12
+ * @example
13
+ * ```bash
14
+ * npx @goodfoot/claude-code-hooks --scaffold ./my-hooks --hooks Stop,SubagentStop -o dist/hooks.json
15
+ * ```
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { HOOK_FACTORY_TO_EVENT } from './constants.js';
20
+ // ============================================================================
21
+ // Constants
22
+ // ============================================================================
23
+ /**
24
+ * Valid hook event names (derived from HOOK_FACTORY_TO_EVENT values).
25
+ */
26
+ const VALID_HOOK_EVENT_NAMES = new Set(Object.values(HOOK_FACTORY_TO_EVENT));
27
+ /**
28
+ * Case-insensitive lookup map for hook event names.
29
+ * Built once at module load to avoid recreation on each validation call.
30
+ */
31
+ const CASE_INSENSITIVE_EVENT_LOOKUP = new Map(
32
+ Array.from(VALID_HOOK_EVENT_NAMES).map((name) => [name.toLowerCase(), name])
33
+ );
34
+ /**
35
+ * Mapping from hook event name to factory function name.
36
+ */
37
+ const EVENT_TO_HOOK_FACTORY = Object.fromEntries(
38
+ Object.entries(HOOK_FACTORY_TO_EVENT).map(([factory, event]) => [event, factory])
39
+ );
40
+ /**
41
+ * Mapping from hook event name to output function name.
42
+ */
43
+ const EVENT_TO_OUTPUT_FUNCTION = {
44
+ PreToolUse: 'preToolUseOutput',
45
+ PostToolUse: 'postToolUseOutput',
46
+ PostToolUseFailure: 'postToolUseFailureOutput',
47
+ Notification: 'notificationOutput',
48
+ UserPromptSubmit: 'userPromptSubmitOutput',
49
+ SessionStart: 'sessionStartOutput',
50
+ SessionEnd: 'sessionEndOutput',
51
+ Stop: 'stopOutput',
52
+ SubagentStart: 'subagentStartOutput',
53
+ SubagentStop: 'subagentStopOutput',
54
+ PreCompact: 'preCompactOutput',
55
+ PermissionRequest: 'permissionRequestOutput'
56
+ };
57
+ // ============================================================================
58
+ // Validation
59
+ // ============================================================================
60
+ /**
61
+ * Validates hook names against valid hook event names.
62
+ *
63
+ * Accepts PascalCase names case-insensitively (e.g., 'stop' -> 'Stop').
64
+ * @param hookNames - Array of hook names to validate
65
+ * @returns Object with normalized hook names or error message
66
+ */
67
+ function validateHookNames(hookNames) {
68
+ const normalized = [];
69
+ const invalid = [];
70
+ for (const hookName of hookNames) {
71
+ const normalizedName = CASE_INSENSITIVE_EVENT_LOOKUP.get(hookName.toLowerCase());
72
+ if (normalizedName !== undefined) {
73
+ normalized.push(normalizedName);
74
+ } else {
75
+ invalid.push(hookName);
76
+ }
77
+ }
78
+ if (invalid.length > 0) {
79
+ const validNames = Array.from(VALID_HOOK_EVENT_NAMES).sort().join(', ');
80
+ return {
81
+ valid: false,
82
+ error: `Invalid hook name(s): ${invalid.join(', ')}\nValid hook names: ${validNames}`
83
+ };
84
+ }
85
+ return { valid: true, normalized };
86
+ }
87
+ // ============================================================================
88
+ // File Generation
89
+ // ============================================================================
90
+ /**
91
+ * Converts a PascalCase hook event name to kebab-case filename.
92
+ * @param eventName - Hook event name (e.g., 'PreToolUse')
93
+ * @returns Kebab-case filename (e.g., 'pre-tool-use')
94
+ */
95
+ function toKebabCase(eventName) {
96
+ return eventName
97
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
98
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
99
+ .toLowerCase();
100
+ }
101
+ /**
102
+ * Generates package.json content for the scaffolded project.
103
+ * @param projectName - Name derived from directory basename
104
+ * @param outputPath - Relative path for hooks.json output
105
+ * @returns JSON string for package.json
106
+ */
107
+ function generatePackageJson(projectName, outputPath) {
108
+ const packageJson = {
109
+ name: projectName,
110
+ version: '1.0.0',
111
+ type: 'module',
112
+ scripts: {
113
+ build: `claude-code-hooks -i "src/**/*.ts" -o "${outputPath}"`,
114
+ test: 'vitest run',
115
+ lint: 'biome check .',
116
+ typecheck: 'tsc --noEmit'
117
+ },
118
+ dependencies: {
119
+ '@goodfoot/claude-code-hooks': '^1.0.0'
120
+ },
121
+ devDependencies: {
122
+ '@biomejs/biome': '^1.9.0',
123
+ '@types/node': '^20.0.0',
124
+ typescript: '^5.6.0',
125
+ vitest: '^2.0.0'
126
+ },
127
+ engines: {
128
+ node: '>=20.11.0'
129
+ }
130
+ };
131
+ return JSON.stringify(packageJson, null, 2) + '\n';
132
+ }
133
+ /**
134
+ * Generates tsconfig.json content for the scaffolded project.
135
+ * @returns JSON string for tsconfig.json
136
+ */
137
+ function generateTsConfig() {
138
+ const tsconfig = {
139
+ compilerOptions: {
140
+ target: 'ES2022',
141
+ module: 'NodeNext',
142
+ moduleResolution: 'NodeNext',
143
+ strict: true,
144
+ esModuleInterop: true,
145
+ skipLibCheck: true,
146
+ declaration: true,
147
+ declarationMap: true,
148
+ outDir: './dist',
149
+ rootDir: './src'
150
+ },
151
+ include: ['src/**/*.ts'],
152
+ exclude: ['node_modules', 'dist', 'test']
153
+ };
154
+ return JSON.stringify(tsconfig, null, 2) + '\n';
155
+ }
156
+ /**
157
+ * Generates biome.json content for the scaffolded project.
158
+ *
159
+ * The ignore array is formatted on one line to match biome's own formatting preferences.
160
+ * This avoids lint errors when running `biome check` on the generated project.
161
+ * @returns JSON string for biome.json
162
+ */
163
+ function generateBiomeConfig() {
164
+ // Generate JSON manually to ensure ignore array is on one line
165
+ // (biome's formatter expects this format)
166
+ return `{
167
+ "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
168
+ "organizeImports": {
169
+ "enabled": true
170
+ },
171
+ "formatter": {
172
+ "enabled": true,
173
+ "indentStyle": "space",
174
+ "indentWidth": 2,
175
+ "lineWidth": 120
176
+ },
177
+ "linter": {
178
+ "enabled": true,
179
+ "rules": {
180
+ "recommended": true
181
+ }
182
+ },
183
+ "files": {
184
+ "ignore": ["node_modules", "dist", "build", "*.json"]
185
+ }
186
+ }
187
+ `;
188
+ }
189
+ /**
190
+ * Generates vitest.config.ts content for the scaffolded project.
191
+ *
192
+ * Uses double quotes and trailing commas to match biome's formatting preferences.
193
+ * @returns TypeScript content for vitest.config.ts
194
+ */
195
+ function generateVitestConfig() {
196
+ return `import { defineConfig } from "vitest/config";
197
+
198
+ export default defineConfig({
199
+ test: {
200
+ include: ["test/**/*.test.ts"],
201
+ globals: false,
202
+ },
203
+ });
204
+ `;
205
+ }
206
+ /**
207
+ * Generates CLAUDE.md content for the scaffolded project.
208
+ * @returns Markdown content for CLAUDE.md
209
+ */
210
+ function generateClaudeMd() {
211
+ return 'Load the `claude-code-hooks:claude-code-hooks` skill immediately if it is available.\n';
212
+ }
213
+ /**
214
+ * Generates .gitignore content for the scaffolded project.
215
+ * @returns Content for .gitignore
216
+ */
217
+ function generateGitignore() {
218
+ return `# Dependencies
219
+ node_modules/
220
+
221
+ # Build outputs
222
+ dist/
223
+ build/
224
+ *.mjs
225
+
226
+ # Generated files
227
+ hooks.json
228
+
229
+ # IDE
230
+ .idea/
231
+ .vscode/
232
+ *.swp
233
+ *.swo
234
+
235
+ # OS
236
+ .DS_Store
237
+ Thumbs.db
238
+
239
+ # Logs
240
+ *.log
241
+ `;
242
+ }
243
+ /**
244
+ * Generates README.md content for the scaffolded project.
245
+ * @param projectName - Name of the project
246
+ * @param hooks - Array of hook event names included in this project
247
+ * @returns Markdown content for README.md
248
+ */
249
+ function generateReadme(projectName, hooks) {
250
+ const hookList = hooks.map((h) => `\`${h}\``).join(', ');
251
+ return `# ${projectName}
252
+
253
+ This project contains [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) built with the \`@goodfoot/claude-code-hooks\` library. Hooks let you extend Claude Code's behavior by running custom code at specific points during a session—before or after tool execution, when Claude starts or stops, and more. This project includes hooks for: ${hookList}.
254
+
255
+ To get started, run \`npm install\` to install dependencies, then \`npm run build\` to compile your hooks into \`hooks.json\`. Copy the generated \`hooks.json\` to your Claude Code settings directory (or reference it in your \`.claude/settings.json\`), and your hooks will run automatically. Edit the files in \`src/\` to customize behavior, and use \`npm test\` to verify your changes work correctly.
256
+ `;
257
+ }
258
+ /**
259
+ * Generates a hook template file for a specific hook type.
260
+ *
261
+ * Uses double quotes for all strings to match biome's formatting preferences.
262
+ * @param eventName - Hook event name (e.g., 'Stop')
263
+ * @returns TypeScript content for the hook file
264
+ */
265
+ function generateHookTemplate(eventName) {
266
+ const factoryName = EVENT_TO_HOOK_FACTORY[eventName];
267
+ const outputName = EVENT_TO_OUTPUT_FUNCTION[eventName];
268
+ // Generate appropriate return statement based on hook type
269
+ // All hooks include systemMessage to demonstrate the pattern
270
+ let returnStatement;
271
+ switch (eventName) {
272
+ case 'Stop':
273
+ case 'SubagentStop':
274
+ returnStatement = `return ${outputName}({
275
+ decision: "approve",
276
+ systemMessage: "${eventName} hook executed successfully.",
277
+ });`;
278
+ break;
279
+ case 'PreToolUse':
280
+ returnStatement = `return ${outputName}({
281
+ systemMessage: "Tool execution allowed by ${eventName} hook.",
282
+ hookSpecificOutput: { permissionDecision: "allow" },
283
+ });`;
284
+ break;
285
+ case 'PostToolUse':
286
+ case 'PostToolUseFailure':
287
+ returnStatement = `return ${outputName}({
288
+ systemMessage: "${eventName} hook processed the result.",
289
+ hookSpecificOutput: { additionalContext: "Hook completed successfully." },
290
+ });`;
291
+ break;
292
+ case 'SessionStart':
293
+ returnStatement = `return ${outputName}({
294
+ systemMessage: "Session initialized by ${eventName} hook.",
295
+ hookSpecificOutput: { additionalContext: "Environment ready." },
296
+ });`;
297
+ break;
298
+ case 'UserPromptSubmit':
299
+ returnStatement = `return ${outputName}({
300
+ systemMessage: "User prompt received.",
301
+ hookSpecificOutput: { additionalContext: "Prompt processed by hook." },
302
+ });`;
303
+ break;
304
+ case 'PreCompact':
305
+ returnStatement = `return ${outputName}({
306
+ systemMessage: "Remember: Context is being compacted.",
307
+ });`;
308
+ break;
309
+ case 'PermissionRequest':
310
+ returnStatement = `return ${outputName}({
311
+ systemMessage: "Permission request processed.",
312
+ });`;
313
+ break;
314
+ default:
315
+ // SessionEnd, Notification, SubagentStart use simple output with systemMessage
316
+ returnStatement = `return ${outputName}({
317
+ systemMessage: "${eventName} hook executed.",
318
+ });`;
319
+ }
320
+ // SessionStart hooks get extended context with persistEnvVar
321
+ const contextDestructure = eventName === 'SessionStart' ? '{ logger, persistEnvVar }' : '{ logger }';
322
+ return `/**
323
+ * ${eventName} hook implementation.
324
+ *
325
+ * @see https://code.claude.com/docs/en/hooks#${eventName.toLowerCase()}
326
+ */
327
+
328
+ import { ${factoryName}, ${outputName} } from "@goodfoot/claude-code-hooks";
329
+
330
+ export default ${factoryName}({}, (input, ${contextDestructure}) => {
331
+ logger.info("${eventName} hook triggered", { input });
332
+ ${returnStatement}
333
+ });
334
+ `;
335
+ }
336
+ /**
337
+ * Generates a test file for a specific hook type.
338
+ *
339
+ * Uses double quotes, alphabetical import order (describe, expect, it),
340
+ * and trailing commas to match biome's formatting preferences.
341
+ * Uses the real Logger class (silent by default) instead of mocks.
342
+ * @param eventName - Hook event name (e.g., 'Stop')
343
+ * @param hookFilename - Kebab-case filename of the hook (e.g., 'stop')
344
+ * @returns TypeScript content for the test file
345
+ */
346
+ function generateTestFile(eventName, hookFilename) {
347
+ // SessionStart hooks need extended context with persistEnvVar
348
+ const contextCode =
349
+ eventName === 'SessionStart'
350
+ ? `{
351
+ logger,
352
+ persistEnvVar: () => {},
353
+ persistEnvVars: () => {},
354
+ }`
355
+ : '{ logger }';
356
+ return `/**
357
+ * Tests for the ${eventName} hook.
358
+ */
359
+
360
+ import { describe, expect, it } from "vitest";
361
+ import { Logger } from "@goodfoot/claude-code-hooks";
362
+ import hook from "../src/${hookFilename}.js";
363
+
364
+ // Logger is silent by default (no stdout/stderr output) — no mocking needed
365
+ const logger = new Logger();
366
+
367
+ describe("${eventName} Hook", () => {
368
+ it("exports a valid hook function", () => {
369
+ expect(hook).toBeDefined();
370
+ expect(typeof hook).toBe("function");
371
+ });
372
+
373
+ it("has correct hookEventName metadata", () => {
374
+ expect(hook.hookEventName).toBe("${eventName}");
375
+ });
376
+
377
+ it("returns a valid output shape", async () => {
378
+ const mockInput = {} as Parameters<typeof hook>[0];
379
+ const context = ${contextCode};
380
+
381
+ const result = await hook(mockInput, context);
382
+
383
+ // Verify output has expected structure
384
+ expect(result).toBeDefined();
385
+ expect(result).toHaveProperty("_type", "${eventName}");
386
+ expect(result).toHaveProperty("stdout");
387
+ expect(typeof result.stdout).toBe("object");
388
+ });
389
+ });
390
+ `;
391
+ }
392
+ // ============================================================================
393
+ // Main Scaffold Function
394
+ // ============================================================================
395
+ /**
396
+ * Scaffolds a new Claude Code hook project.
397
+ *
398
+ * Creates the complete project structure including:
399
+ * - package.json, tsconfig.json, biome.json, vitest.config.ts
400
+ * - src/ directory with hook implementations
401
+ * - test/ directory with vitest tests
402
+ * @param options - Scaffold configuration options
403
+ * @throws Exits with code 1 if directory exists or hook names are invalid
404
+ * @example
405
+ * ```typescript
406
+ * scaffoldProject({
407
+ * directory: './my-hooks',
408
+ * hooks: ['Stop', 'SubagentStop'],
409
+ * outputPath: 'dist/hooks.json'
410
+ * });
411
+ * ```
412
+ */
413
+ export function scaffoldProject(options) {
414
+ const { directory, hooks, outputPath } = options;
415
+ // Resolve to absolute path
416
+ const absoluteDir = path.resolve(process.cwd(), directory);
417
+ // Check if directory already exists
418
+ if (fs.existsSync(absoluteDir)) {
419
+ process.stderr.write(`Error: Directory already exists: ${absoluteDir}\n`);
420
+ process.exit(1);
421
+ }
422
+ // Validate hook names
423
+ const validation = validateHookNames(hooks);
424
+ if (!validation.valid) {
425
+ process.stderr.write(`Error: ${validation.error}\n`);
426
+ process.exit(1);
427
+ }
428
+ const normalizedHooks = validation.normalized;
429
+ // Create directory structure
430
+ const srcDir = path.join(absoluteDir, 'src');
431
+ const testDir = path.join(absoluteDir, 'test');
432
+ fs.mkdirSync(absoluteDir, { recursive: true });
433
+ fs.mkdirSync(srcDir, { recursive: true });
434
+ fs.mkdirSync(testDir, { recursive: true });
435
+ // Generate project name from directory basename
436
+ const projectName = path.basename(absoluteDir);
437
+ // Generate configuration files
438
+ fs.writeFileSync(path.join(absoluteDir, 'package.json'), generatePackageJson(projectName, outputPath));
439
+ fs.writeFileSync(path.join(absoluteDir, 'tsconfig.json'), generateTsConfig());
440
+ fs.writeFileSync(path.join(absoluteDir, 'biome.json'), generateBiomeConfig());
441
+ fs.writeFileSync(path.join(absoluteDir, 'vitest.config.ts'), generateVitestConfig());
442
+ fs.writeFileSync(path.join(absoluteDir, 'CLAUDE.md'), generateClaudeMd());
443
+ fs.writeFileSync(path.join(absoluteDir, '.gitignore'), generateGitignore());
444
+ fs.writeFileSync(path.join(absoluteDir, 'README.md'), generateReadme(projectName, normalizedHooks));
445
+ // Generate hook files and tests
446
+ for (const eventName of normalizedHooks) {
447
+ const kebabName = toKebabCase(eventName);
448
+ // Generate hook file
449
+ const hookContent = generateHookTemplate(eventName);
450
+ fs.writeFileSync(path.join(srcDir, `${kebabName}.ts`), hookContent);
451
+ // Generate test file
452
+ const testContent = generateTestFile(eventName, kebabName);
453
+ fs.writeFileSync(path.join(testDir, `${kebabName}.test.ts`), testContent);
454
+ }
455
+ // Output success message
456
+ process.stdout.write(`Created hook project at ${absoluteDir}\n`);
457
+ process.stdout.write('\nNext steps:\n');
458
+ process.stdout.write(` cd ${directory}\n`);
459
+ process.stdout.write(' npm install\n');
460
+ process.stdout.write(' npm run build\n');
461
+ process.stdout.write('\nGenerated hooks:\n');
462
+ for (const eventName of normalizedHooks) {
463
+ const kebabName = toKebabCase(eventName);
464
+ process.stdout.write(` - src/${kebabName}.ts\n`);
465
+ }
466
+ }