claude-git-hooks 2.0.0 → 2.3.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 (47) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +217 -92
  3. package/bin/claude-hooks +311 -149
  4. package/lib/config.js +163 -0
  5. package/lib/hooks/pre-commit.js +180 -68
  6. package/lib/hooks/prepare-commit-msg.js +47 -41
  7. package/lib/utils/claude-client.js +93 -11
  8. package/lib/utils/file-operations.js +23 -74
  9. package/lib/utils/file-utils.js +65 -0
  10. package/lib/utils/package-info.js +75 -0
  11. package/lib/utils/preset-loader.js +209 -0
  12. package/lib/utils/prompt-builder.js +83 -67
  13. package/lib/utils/resolution-prompt.js +12 -2
  14. package/package.json +49 -50
  15. package/templates/ANALYZE_DIFF.md +33 -0
  16. package/templates/COMMIT_MESSAGE.md +24 -0
  17. package/templates/SUBAGENT_INSTRUCTION.md +1 -0
  18. package/templates/config.example.json +41 -0
  19. package/templates/presets/ai/ANALYSIS_PROMPT.md +133 -0
  20. package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +176 -0
  21. package/templates/presets/ai/config.json +12 -0
  22. package/templates/presets/ai/preset.json +42 -0
  23. package/templates/presets/backend/ANALYSIS_PROMPT.md +85 -0
  24. package/templates/presets/backend/PRE_COMMIT_GUIDELINES.md +87 -0
  25. package/templates/presets/backend/config.json +12 -0
  26. package/templates/presets/backend/preset.json +49 -0
  27. package/templates/presets/database/ANALYSIS_PROMPT.md +114 -0
  28. package/templates/presets/database/PRE_COMMIT_GUIDELINES.md +143 -0
  29. package/templates/presets/database/config.json +12 -0
  30. package/templates/presets/database/preset.json +38 -0
  31. package/templates/presets/default/config.json +12 -0
  32. package/templates/presets/default/preset.json +53 -0
  33. package/templates/presets/frontend/ANALYSIS_PROMPT.md +99 -0
  34. package/templates/presets/frontend/PRE_COMMIT_GUIDELINES.md +95 -0
  35. package/templates/presets/frontend/config.json +12 -0
  36. package/templates/presets/frontend/preset.json +50 -0
  37. package/templates/presets/fullstack/ANALYSIS_PROMPT.md +107 -0
  38. package/templates/presets/fullstack/CONSISTENCY_CHECKS.md +147 -0
  39. package/templates/presets/fullstack/PRE_COMMIT_GUIDELINES.md +125 -0
  40. package/templates/presets/fullstack/config.json +12 -0
  41. package/templates/presets/fullstack/preset.json +55 -0
  42. package/templates/shared/ANALYSIS_PROMPT.md +103 -0
  43. package/templates/shared/ANALYZE_DIFF.md +33 -0
  44. package/templates/shared/COMMIT_MESSAGE.md +24 -0
  45. package/templates/shared/PRE_COMMIT_GUIDELINES.md +145 -0
  46. package/templates/shared/RESOLUTION_PROMPT.md +32 -0
  47. package/templates/check-version.sh +0 -266
@@ -16,8 +16,10 @@
16
16
 
17
17
  import { spawn, execSync } from 'child_process';
18
18
  import fs from 'fs/promises';
19
+ import path from 'path';
19
20
  import os from 'os';
20
21
  import logger from './logger.js';
22
+ import config from '../config.js';
21
23
 
22
24
  /**
23
25
  * Custom error for Claude client failures
@@ -53,7 +55,7 @@ const isWSLAvailable = () => {
53
55
 
54
56
  try {
55
57
  // Try to run wsl --version to check if WSL is installed
56
- execSync('wsl --version', { stdio: 'ignore', timeout: 3000 });
58
+ execSync('wsl --version', { stdio: 'ignore', timeout: config.system.wslCheckTimeout });
57
59
  return true;
58
60
  } catch (error) {
59
61
  logger.debug('claude-client - isWSLAvailable', 'WSL not available', error);
@@ -294,15 +296,45 @@ const extractJSON = (response) => {
294
296
  };
295
297
 
296
298
  /**
297
- * Saves response to debug file
298
- * Why: Helps troubleshoot issues with Claude responses
299
+ * Saves prompt and response to debug file
300
+ * Why: Helps troubleshoot issues with Claude responses and verify prompts
299
301
  *
300
- * @param {string} response - Raw response to save
301
- * @param {string} filename - Debug filename (default: 'debug-claude-response.json')
302
+ * @param {string} prompt - Prompt sent to Claude
303
+ * @param {string} response - Raw response from Claude
304
+ * @param {string} filename - Debug filename (default: from config)
302
305
  */
303
- const saveDebugResponse = async (response, filename = 'debug-claude-response.json') => {
306
+ const saveDebugResponse = async (prompt, response, filename = config.output.debugFile) => {
304
307
  try {
305
- await fs.writeFile(filename, response, 'utf8');
308
+ // Ensure output directory exists
309
+ const outputDir = path.dirname(filename);
310
+ await fs.mkdir(outputDir, { recursive: true });
311
+
312
+ // Save full debug information
313
+ const debugData = {
314
+ timestamp: new Date().toISOString(),
315
+ promptLength: prompt.length,
316
+ responseLength: response.length,
317
+ prompt: prompt,
318
+ response: response
319
+ };
320
+
321
+ await fs.writeFile(filename, JSON.stringify(debugData, null, 2), 'utf8');
322
+
323
+ // Display batch optimization status
324
+ try {
325
+ if (prompt.includes('OPTIMIZATION')) {
326
+ console.log('\n' + '='.repeat(70));
327
+ console.log('✅ BATCH OPTIMIZATION ENABLED');
328
+ console.log('='.repeat(70));
329
+ console.log('Multi-file analysis organized for efficient processing');
330
+ console.log('Check debug file for full prompt and response details');
331
+ console.log('='.repeat(70) + '\n');
332
+ }
333
+ } catch (parseError) {
334
+ // Ignore parsing errors, just skip the display
335
+ }
336
+
337
+ logger.info(`📝 Debug output saved to ${filename}`);
306
338
  logger.debug(
307
339
  'claude-client - saveDebugResponse',
308
340
  `Debug response saved to ${filename}`
@@ -323,11 +355,11 @@ const saveDebugResponse = async (response, filename = 'debug-claude-response.jso
323
355
  * @param {string} prompt - Analysis prompt
324
356
  * @param {Object} options - Analysis options
325
357
  * @param {number} options.timeout - Timeout in milliseconds
326
- * @param {boolean} options.saveDebug - Save response to debug file (default: from DEBUG env)
358
+ * @param {boolean} options.saveDebug - Save response to debug file (default: from config)
327
359
  * @returns {Promise<Object>} Parsed analysis result
328
360
  * @throws {ClaudeClientError} If analysis fails
329
361
  */
330
- const analyzeCode = async (prompt, { timeout = 120000, saveDebug = process.env.DEBUG === 'true' } = {}) => {
362
+ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug } = {}) => {
331
363
  logger.debug(
332
364
  'claude-client - analyzeCode',
333
365
  'Starting code analysis',
@@ -340,7 +372,7 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = process.env.D
340
372
 
341
373
  // Save debug if requested
342
374
  if (saveDebug) {
343
- await saveDebugResponse(response);
375
+ await saveDebugResponse(prompt, response);
344
376
  }
345
377
 
346
378
  // Extract and parse JSON
@@ -364,10 +396,60 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = process.env.D
364
396
  }
365
397
  };
366
398
 
399
+ /**
400
+ * Splits array into chunks
401
+ * @param {Array} array - Array to split
402
+ * @param {number} size - Chunk size
403
+ * @returns {Array<Array>} Array of chunks
404
+ */
405
+ const chunkArray = (array, size) => {
406
+ const chunks = [];
407
+ for (let i = 0; i < array.length; i += size) {
408
+ chunks.push(array.slice(i, i + size));
409
+ }
410
+ return chunks;
411
+ };
412
+
413
+ /**
414
+ * Runs multiple analyzeCode calls in parallel
415
+ * @param {Array<string>} prompts - Array of prompts to analyze
416
+ * @param {Object} options - Same options as analyzeCode
417
+ * @returns {Promise<Array<Object>>} Array of results
418
+ */
419
+ const analyzeCodeParallel = async (prompts, options = {}) => {
420
+ const startTime = Date.now();
421
+
422
+ console.log('\n' + '='.repeat(70));
423
+ console.log(`🚀 PARALLEL EXECUTION: ${prompts.length} Claude processes`);
424
+ console.log('='.repeat(70));
425
+
426
+ logger.info(`Starting parallel analysis: ${prompts.length} prompts`);
427
+
428
+ const promises = prompts.map((prompt, index) => {
429
+ console.log(` ⚡ Launching batch ${index + 1}/${prompts.length}...`);
430
+ logger.debug('claude-client - analyzeCodeParallel', `Starting batch ${index + 1}`);
431
+ return analyzeCode(prompt, options);
432
+ });
433
+
434
+ console.log(` ⏳ Waiting for all batches to complete...\n`);
435
+ const results = await Promise.all(promises);
436
+
437
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
438
+
439
+ console.log('='.repeat(70));
440
+ console.log(`✅ PARALLEL EXECUTION COMPLETE: ${results.length} results in ${duration}s`);
441
+ console.log('='.repeat(70) + '\n');
442
+
443
+ logger.info(`Parallel analysis complete: ${results.length} results in ${duration}s`);
444
+ return results;
445
+ };
446
+
367
447
  export {
368
448
  ClaudeClientError,
369
449
  executeClaude,
370
450
  extractJSON,
371
451
  saveDebugResponse,
372
- analyzeCode
452
+ analyzeCode,
453
+ analyzeCodeParallel,
454
+ chunkArray
373
455
  };
@@ -121,11 +121,21 @@ const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {})
121
121
  };
122
122
 
123
123
  /**
124
- * Filters SKIP-ANALYSIS patterns from code content
124
+ * Filters SKIP_ANALYSIS patterns from code content
125
125
  * Why: Allows developers to exclude specific code from analysis
126
126
  *
127
+ * ⚠️ KNOWN ISSUE (EXPERIMENTAL/BROKEN):
128
+ * This feature does NOT work reliably. The pre-commit hook analyzes git diff
129
+ * instead of full file content. Markers added in previous commits are NOT
130
+ * present in subsequent diffs, so they are not detected.
131
+ *
132
+ * Example of failure:
133
+ * - Commit 1: Add // SKIP_ANALYSIS_BLOCK markers
134
+ * - Commit 2: Modify line inside block
135
+ * - Result: Diff only shows modified line, NOT the markers → filter fails
136
+ *
127
137
  * Supports two patterns:
128
- * 1. Single line: // SKIP-ANALYSIS (excludes next line)
138
+ * 1. Single line: // SKIP_ANALYSIS_LINE (excludes next line)
129
139
  * 2. Block: // SKIP_ANALYSIS_BLOCK ... // SKIP_ANALYSIS_BLOCK (excludes block)
130
140
  *
131
141
  * @param {string} content - File content to filter
@@ -134,7 +144,7 @@ const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {})
134
144
  const filterSkipAnalysis = (content) => {
135
145
  logger.debug(
136
146
  'file-operations - filterSkipAnalysis',
137
- 'Filtering SKIP-ANALYSIS patterns',
147
+ 'Filtering SKIP_ANALYSIS patterns',
138
148
  { originalLength: content.length }
139
149
  );
140
150
 
@@ -145,11 +155,8 @@ const filterSkipAnalysis = (content) => {
145
155
  // Why: Use map instead of filter to preserve line numbers for error reporting
146
156
  // Empty lines maintain original line number mapping
147
157
  const filteredLines = lines.map((line) => {
148
- // Detect single-line SKIP-ANALYSIS
149
- if (line.includes('// SKIP-ANALYSIS')) {
150
- skipNext = true;
151
- return ''; // Preserve line number
152
- }
158
+ // IMPORTANT: Check specific pattern first (SKIP_ANALYSIS_BLOCK)
159
+ // before general pattern (SKIP_ANALYSIS_LINE) to avoid substring match issues
153
160
 
154
161
  // Detect SKIP_ANALYSIS_BLOCK (toggle block state)
155
162
  if (line.includes('// SKIP_ANALYSIS_BLOCK')) {
@@ -157,12 +164,18 @@ const filterSkipAnalysis = (content) => {
157
164
  return ''; // Preserve line number
158
165
  }
159
166
 
167
+ // Detect single-line SKIP_ANALYSIS_LINE (must check AFTER block check)
168
+ if (line.includes('// SKIP_ANALYSIS_LINE')) {
169
+ skipNext = true;
170
+ return ''; // Preserve line number
171
+ }
172
+
160
173
  // Skip lines inside block
161
174
  if (inSkipBlock) {
162
175
  return '';
163
176
  }
164
177
 
165
- // Skip next line after single SKIP-ANALYSIS
178
+ // Skip next line after single SKIP_ANALYSIS_LINE
166
179
  if (skipNext) {
167
180
  skipNext = false;
168
181
  return '';
@@ -334,69 +347,6 @@ const filterFiles = async (files, { maxSize = 100000, extensions = [] } = {}) =>
334
347
  * }
335
348
  * ]
336
349
  */
337
- const readMultipleFiles = async (files, { maxSize = 100000, applySkipFilter = true } = {}) => {
338
- logger.debug(
339
- 'file-operations - readMultipleFiles',
340
- 'Reading multiple files',
341
- { fileCount: files.length, maxSize, applySkipFilter }
342
- );
343
-
344
- const results = await Promise.allSettled(
345
- files.map(async (filePath) => {
346
- try {
347
- let content = await readFile(filePath, { maxSize });
348
-
349
- // Apply SKIP-ANALYSIS filtering if requested
350
- if (applySkipFilter) {
351
- content = filterSkipAnalysis(content);
352
- }
353
-
354
- const size = await getFileSize(filePath);
355
-
356
- return {
357
- path: filePath,
358
- content,
359
- size,
360
- error: null
361
- };
362
-
363
- } catch (error) {
364
- logger.error(
365
- 'file-operations - readMultipleFiles',
366
- `Failed to read file: ${filePath}`,
367
- error
368
- );
369
-
370
- return {
371
- path: filePath,
372
- content: null,
373
- size: 0,
374
- error
375
- };
376
- }
377
- })
378
- );
379
-
380
- // Extract all results (both successful and failed)
381
- const fileContents = results.map(r =>
382
- r.status === 'fulfilled' ? r.value : r.reason
383
- );
384
-
385
- const successCount = fileContents.filter(f => f.error === null).length;
386
-
387
- logger.debug(
388
- 'file-operations - readMultipleFiles',
389
- 'Reading complete',
390
- {
391
- totalFiles: files.length,
392
- successfulReads: successCount,
393
- failedReads: files.length - successCount
394
- }
395
- );
396
-
397
- return fileContents;
398
- };
399
-
400
350
  export {
401
351
  FileOperationError,
402
352
  getFileSize,
@@ -404,6 +354,5 @@ export {
404
354
  readFile,
405
355
  filterSkipAnalysis,
406
356
  hasAllowedExtension,
407
- filterFiles,
408
- readMultipleFiles
357
+ filterFiles
409
358
  };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * File: file-utils.js
3
+ * Purpose: Utility functions for file system operations
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import fsSync from 'fs';
8
+ import path from 'path';
9
+ import { getRepoRoot } from './git-operations.js';
10
+ import logger from './logger.js';
11
+
12
+ /**
13
+ * Ensures a directory exists (creates if not present)
14
+ *
15
+ * @param {string} dirPath - Directory path to ensure
16
+ * @returns {Promise<void>}
17
+ */
18
+ export const ensureDir = async (dirPath) => {
19
+ const absolutePath = path.isAbsolute(dirPath)
20
+ ? dirPath
21
+ : path.join(getRepoRoot(), dirPath);
22
+
23
+ try {
24
+ await fs.mkdir(absolutePath, { recursive: true });
25
+ logger.debug('file-utils - ensureDir', 'Directory ensured', { path: absolutePath });
26
+ } catch (error) {
27
+ logger.error('file-utils - ensureDir', 'Failed to create directory', error);
28
+ throw error;
29
+ }
30
+ };
31
+
32
+ /**
33
+ * Ensures the output directory exists before writing files
34
+ * Creates .claude/out/ if it doesn't exist
35
+ *
36
+ * @param {Object} config - Configuration object with output.outputDir
37
+ * @returns {Promise<void>}
38
+ */
39
+ export const ensureOutputDir = async (config) => {
40
+ const outputDir = config?.output?.outputDir || '.claude/out';
41
+ await ensureDir(outputDir);
42
+ };
43
+
44
+ /**
45
+ * Writes a file ensuring its directory exists
46
+ *
47
+ * @param {string} filePath - File path to write
48
+ * @param {string} content - File content
49
+ * @param {Object} config - Configuration object
50
+ * @returns {Promise<void>}
51
+ */
52
+ export const writeOutputFile = async (filePath, content, config) => {
53
+ await ensureOutputDir(config);
54
+
55
+ const absolutePath = path.isAbsolute(filePath)
56
+ ? filePath
57
+ : path.join(getRepoRoot(), filePath);
58
+
59
+ await fs.writeFile(absolutePath, content, 'utf8');
60
+
61
+ logger.debug('file-utils - writeOutputFile', 'File written', {
62
+ path: absolutePath,
63
+ size: content.length
64
+ });
65
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * File: package-info.js
3
+ * Purpose: Utility for reading package.json information
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ /**
14
+ * @typedef {Object} PackageData
15
+ * @property {string} name - Package name
16
+ * @property {string} version - Package version
17
+ * @property {string} [description] - Package description
18
+ */
19
+
20
+ /**
21
+ * Cache for package.json to avoid repeated file reads
22
+ * @type {PackageData|undefined}
23
+ */
24
+ let packageCache;
25
+
26
+ /**
27
+ * Gets package.json data with caching (async)
28
+ * @returns {Promise<PackageData>} Package data with name and version
29
+ * @throws {Error} If package.json cannot be read
30
+ */
31
+ export const getPackageJson = async () => {
32
+ // Return cached value if available
33
+ if (packageCache) {
34
+ return packageCache;
35
+ }
36
+
37
+ try {
38
+ const packagePath = path.join(__dirname, '..', '..', 'package.json');
39
+ const content = await fs.readFile(packagePath, 'utf8');
40
+ packageCache = JSON.parse(content);
41
+ return packageCache;
42
+ } catch (error) {
43
+ throw new Error(`Failed to read package.json: ${error.message}`);
44
+ }
45
+ };
46
+
47
+ /**
48
+ * Gets package version (async)
49
+ * @returns {Promise<string>} Package version
50
+ */
51
+ export const getVersion = async () => {
52
+ const pkg = await getPackageJson();
53
+ return pkg.version;
54
+ };
55
+
56
+ /**
57
+ * Gets package name (async)
58
+ * @returns {Promise<string>} Package name
59
+ */
60
+ export const getPackageName = async () => {
61
+ const pkg = await getPackageJson();
62
+ return pkg.name;
63
+ };
64
+
65
+ /**
66
+ * Calculates batch information for subagent processing
67
+ * @param {number} fileCount - Number of files to process
68
+ * @param {number} batchSize - Size of each batch
69
+ * @returns {Object} Batch information { numBatches, shouldShowBatches }
70
+ */
71
+ export const calculateBatches = (fileCount, batchSize) => {
72
+ const numBatches = Math.ceil(fileCount / batchSize);
73
+ const shouldShowBatches = fileCount > batchSize;
74
+ return { numBatches, shouldShowBatches };
75
+ };
@@ -0,0 +1,209 @@
1
+ /**
2
+ * File: preset-loader.js
3
+ * Purpose: Loads preset configurations and metadata
4
+ *
5
+ * Key responsibilities:
6
+ * - Load preset.json (metadata)
7
+ * - Load config.json (overrides)
8
+ * - Resolve template paths
9
+ * - Replace placeholders in templates
10
+ *
11
+ * Dependencies:
12
+ * - fs/promises: Async file operations
13
+ * - path: Cross-platform path handling
14
+ * - git-operations: For getRepoRoot()
15
+ * - logger: Debug and error logging
16
+ */
17
+
18
+ import fs from 'fs/promises';
19
+ import path from 'path';
20
+ import { getRepoRoot } from './git-operations.js';
21
+ import logger from './logger.js';
22
+
23
+ /**
24
+ * Custom error for preset loading failures
25
+ */
26
+ class PresetError extends Error {
27
+ constructor(message, { presetName, cause } = {}) {
28
+ super(message);
29
+ this.name = 'PresetError';
30
+ this.presetName = presetName;
31
+ this.cause = cause;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Loads preset metadata + config
37
+ * @param {string} presetName - Name of preset (backend, frontend, etc.)
38
+ * @returns {Promise<Object>} { metadata, config, templates }
39
+ */
40
+ export async function loadPreset(presetName) {
41
+ logger.debug(
42
+ 'preset-loader - loadPreset',
43
+ 'Loading preset',
44
+ { presetName }
45
+ );
46
+
47
+ const repoRoot = getRepoRoot();
48
+
49
+ // Only try .claude/presets/{name} (installed by claude-hooks install)
50
+ const presetDir = path.join(repoRoot, '.claude', 'presets', presetName);
51
+
52
+ logger.debug('preset-loader - loadPreset', 'Loading preset from', { presetDir });
53
+
54
+ try {
55
+ // Load preset.json (metadata)
56
+ const presetJsonPath = path.join(presetDir, 'preset.json');
57
+ const metadataRaw = await fs.readFile(presetJsonPath, 'utf8');
58
+ const metadata = JSON.parse(metadataRaw);
59
+
60
+ // Load config.json (overrides)
61
+ const configJsonPath = path.join(presetDir, 'config.json');
62
+ const configRaw = await fs.readFile(configJsonPath, 'utf8');
63
+ const config = JSON.parse(configRaw);
64
+
65
+ // Resolve template paths
66
+ const templates = {};
67
+ for (const [key, templatePath] of Object.entries(metadata.templates)) {
68
+ templates[key] = path.join(presetDir, templatePath);
69
+ }
70
+
71
+ logger.debug(
72
+ 'preset-loader - loadPreset',
73
+ 'Preset loaded successfully',
74
+ {
75
+ presetName,
76
+ displayName: metadata.displayName,
77
+ fileExtensions: metadata.fileExtensions,
78
+ templateCount: Object.keys(templates).length
79
+ }
80
+ );
81
+
82
+ return { metadata, config, templates };
83
+
84
+ } catch (error) {
85
+ logger.error(
86
+ 'preset-loader - loadPreset',
87
+ `Failed to load preset: ${presetName}`,
88
+ error
89
+ );
90
+
91
+ throw new PresetError(`Preset "${presetName}" not found or invalid`, {
92
+ presetName,
93
+ cause: error
94
+ });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Loads a template file and replaces placeholders
100
+ * @param {string} templatePath - Absolute path to template file
101
+ * @param {Object} metadata - Preset metadata for placeholder replacement
102
+ * @returns {Promise<string>} Template content with placeholders replaced
103
+ */
104
+ export async function loadTemplate(templatePath, metadata) {
105
+ logger.debug(
106
+ 'preset-loader - loadTemplate',
107
+ 'Loading template',
108
+ { templatePath }
109
+ );
110
+
111
+ try {
112
+ let template = await fs.readFile(templatePath, 'utf8');
113
+
114
+ // Replace placeholders
115
+ template = template.replace(
116
+ /{{TECH_STACK}}/g,
117
+ metadata.techStack.join(', ')
118
+ );
119
+ template = template.replace(
120
+ /{{FOCUS_AREAS}}/g,
121
+ metadata.focusAreas.join(', ')
122
+ );
123
+ template = template.replace(
124
+ /{{FILE_EXTENSIONS}}/g,
125
+ metadata.fileExtensions.join(', ')
126
+ );
127
+ template = template.replace(
128
+ /{{PRESET_NAME}}/g,
129
+ metadata.name
130
+ );
131
+
132
+ logger.debug(
133
+ 'preset-loader - loadTemplate',
134
+ 'Template loaded and processed',
135
+ {
136
+ templatePath,
137
+ originalLength: template.length
138
+ }
139
+ );
140
+
141
+ return template;
142
+
143
+ } catch (error) {
144
+ logger.error(
145
+ 'preset-loader - loadTemplate',
146
+ `Failed to load template: ${templatePath}`,
147
+ error
148
+ );
149
+
150
+ throw new PresetError(`Template not found: ${templatePath}`, {
151
+ cause: error
152
+ });
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Lists all available presets in .claude/presets/
158
+ * @returns {Promise<Array<Object>>} Array of preset metadata
159
+ * Returns: [{ name, displayName, description }]
160
+ */
161
+ export async function listPresets() {
162
+ logger.debug('preset-loader - listPresets', 'Listing available presets');
163
+
164
+ const repoRoot = getRepoRoot();
165
+ const presets = [];
166
+
167
+ // Load all presets from .claude/presets/ (installed by claude-hooks install)
168
+ const presetsDir = path.join(repoRoot, '.claude', 'presets');
169
+ try {
170
+ const presetNames = await fs.readdir(presetsDir);
171
+
172
+ for (const name of presetNames) {
173
+ const presetJsonPath = path.join(presetsDir, name, 'preset.json');
174
+ try {
175
+ const metadataRaw = await fs.readFile(presetJsonPath, 'utf8');
176
+ const metadata = JSON.parse(metadataRaw);
177
+
178
+ presets.push({
179
+ name: metadata.name,
180
+ displayName: metadata.displayName,
181
+ description: metadata.description
182
+ });
183
+ } catch (error) {
184
+ logger.debug(
185
+ 'preset-loader - listPresets',
186
+ `Skipping invalid preset: ${name}`,
187
+ error
188
+ );
189
+ }
190
+ }
191
+ } catch (error) {
192
+ logger.warning('No presets directory found. Run "claude-hooks install" first.');
193
+ logger.debug(
194
+ 'preset-loader - listPresets',
195
+ 'Failed to read presets directory',
196
+ error
197
+ );
198
+ }
199
+
200
+ logger.debug(
201
+ 'preset-loader - listPresets',
202
+ 'Presets listed',
203
+ { count: presets.length }
204
+ );
205
+
206
+ return presets;
207
+ }
208
+
209
+ export { PresetError };