claude-git-hooks 2.4.0 → 2.5.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/CHANGELOG.md +262 -135
- package/README.md +158 -67
- package/bin/claude-hooks +452 -10
- package/lib/config.js +29 -0
- package/lib/hooks/pre-commit.js +2 -6
- package/lib/hooks/prepare-commit-msg.js +27 -4
- package/lib/utils/claude-client.js +148 -16
- package/lib/utils/file-operations.js +0 -102
- package/lib/utils/github-api.js +641 -0
- package/lib/utils/github-client.js +770 -0
- package/lib/utils/interactive-ui.js +314 -0
- package/lib/utils/mcp-setup.js +342 -0
- package/lib/utils/sanitize.js +180 -0
- package/lib/utils/task-id.js +425 -0
- package/package.json +4 -1
- package/templates/CREATE_GITHUB_PR.md +32 -0
- package/templates/config.example.json +41 -41
- package/templates/config.github.example.json +51 -0
- package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +18 -1
- package/templates/presets/ai/config.json +12 -12
- package/templates/presets/ai/preset.json +37 -42
- package/templates/presets/backend/ANALYSIS_PROMPT.md +23 -28
- package/templates/presets/backend/PRE_COMMIT_GUIDELINES.md +41 -3
- package/templates/presets/backend/config.json +12 -12
- package/templates/presets/database/config.json +12 -12
- package/templates/presets/default/config.json +12 -12
- package/templates/presets/frontend/config.json +12 -12
- package/templates/presets/fullstack/config.json +12 -12
- package/templates/settings.local.example.json +4 -0
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
import fs from 'fs/promises';
|
|
21
21
|
import { getStagedFiles, getStagedStats, getFileDiff } from '../utils/git-operations.js';
|
|
22
22
|
import { analyzeCode } from '../utils/claude-client.js';
|
|
23
|
-
import { filterSkipAnalysis } from '../utils/file-operations.js';
|
|
24
23
|
import { loadPrompt } from '../utils/prompt-builder.js';
|
|
25
24
|
import { getVersion, calculateBatches } from '../utils/package-info.js';
|
|
26
25
|
import logger from '../utils/logger.js';
|
|
27
26
|
import { getConfig } from '../config.js';
|
|
27
|
+
import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Builds commit message generation prompt
|
|
@@ -179,7 +179,21 @@ const main = async () => {
|
|
|
179
179
|
|
|
180
180
|
logger.info('Generating commit message automatically...');
|
|
181
181
|
|
|
182
|
-
//
|
|
182
|
+
// Step 1: Auto-detect task ID from branch (no prompt)
|
|
183
|
+
logger.debug('prepare-commit-msg - main', 'Detecting task ID from branch');
|
|
184
|
+
const taskId = await getOrPromptTaskId({
|
|
185
|
+
prompt: false, // Don't prompt - just detect from branch
|
|
186
|
+
required: false,
|
|
187
|
+
config: config // Pass config for custom pattern
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (taskId) {
|
|
191
|
+
logger.info(`📋 Task ID detected: ${taskId}`);
|
|
192
|
+
} else {
|
|
193
|
+
logger.debug('prepare-commit-msg - main', 'No task ID found in branch, continuing without it');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Step 2: Get staged files
|
|
183
197
|
const stagedFiles = getStagedFiles();
|
|
184
198
|
|
|
185
199
|
if (stagedFiles.length === 0) {
|
|
@@ -208,7 +222,6 @@ const main = async () => {
|
|
|
208
222
|
let diff = null;
|
|
209
223
|
if (fileStats.size < config.analysis.maxFileSize) {
|
|
210
224
|
diff = getFileDiff(filePath);
|
|
211
|
-
diff = filterSkipAnalysis(diff);
|
|
212
225
|
}
|
|
213
226
|
|
|
214
227
|
return {
|
|
@@ -265,7 +278,17 @@ const main = async () => {
|
|
|
265
278
|
);
|
|
266
279
|
|
|
267
280
|
// Format message
|
|
268
|
-
|
|
281
|
+
let message = formatCommitMessage(response);
|
|
282
|
+
|
|
283
|
+
// Add task ID prefix if available
|
|
284
|
+
if (taskId) {
|
|
285
|
+
message = formatWithTaskId(message, taskId);
|
|
286
|
+
logger.debug(
|
|
287
|
+
'prepare-commit-msg - main',
|
|
288
|
+
'Task ID added to message',
|
|
289
|
+
{ taskId, message: message.split('\n')[0] }
|
|
290
|
+
);
|
|
291
|
+
}
|
|
269
292
|
|
|
270
293
|
// Write to commit message file
|
|
271
294
|
await fs.writeFile(commitMsgFile, message + '\n', 'utf8');
|
|
@@ -67,22 +67,49 @@ const isWSLAvailable = () => {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Get Claude command configuration for current platform
|
|
70
|
-
* Why: On Windows,
|
|
70
|
+
* Why: On Windows, try native Claude first, then WSL as fallback
|
|
71
71
|
*
|
|
72
72
|
* @returns {Object} { command, args } - Command and base arguments
|
|
73
|
-
* @throws {ClaudeClientError} If
|
|
73
|
+
* @throws {ClaudeClientError} If Claude not available on any method
|
|
74
74
|
*/
|
|
75
75
|
const getClaudeCommand = () => {
|
|
76
76
|
if (isWindows()) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
// Try native Windows Claude first (e.g., installed via npm/scoop/choco)
|
|
78
|
+
try {
|
|
79
|
+
execSync('claude --version', { stdio: 'ignore', timeout: 3000 });
|
|
80
|
+
logger.debug('claude-client - getClaudeCommand', 'Using native Windows Claude CLI');
|
|
81
|
+
return { command: 'claude', args: [] };
|
|
82
|
+
} catch (nativeError) {
|
|
83
|
+
logger.debug('claude-client - getClaudeCommand', 'Native Claude not found, trying WSL');
|
|
84
|
+
|
|
85
|
+
// Fallback to WSL
|
|
86
|
+
if (!isWSLAvailable()) {
|
|
87
|
+
throw new ClaudeClientError('Claude CLI not found. Install Claude CLI natively on Windows or via WSL', {
|
|
88
|
+
context: {
|
|
89
|
+
platform: 'Windows',
|
|
90
|
+
suggestions: [
|
|
91
|
+
'Native Windows: npm install -g @anthropic-ai/claude-cli',
|
|
92
|
+
'WSL: wsl --install, then install Claude in WSL'
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if Claude is available in WSL
|
|
99
|
+
try {
|
|
100
|
+
execSync('wsl claude --version', { stdio: 'ignore', timeout: 5000 });
|
|
101
|
+
logger.debug('claude-client - getClaudeCommand', 'Using WSL Claude CLI');
|
|
102
|
+
return { command: 'wsl', args: ['claude'] };
|
|
103
|
+
} catch (wslError) {
|
|
104
|
+
throw new ClaudeClientError('Claude CLI not found in Windows or WSL', {
|
|
105
|
+
context: {
|
|
106
|
+
platform: 'Windows',
|
|
107
|
+
nativeError: nativeError.message,
|
|
108
|
+
wslError: wslError.message
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
84
112
|
}
|
|
85
|
-
return { command: 'wsl', args: ['claude'] };
|
|
86
113
|
}
|
|
87
114
|
return { command: 'claude', args: [] };
|
|
88
115
|
};
|
|
@@ -98,24 +125,34 @@ const getClaudeCommand = () => {
|
|
|
98
125
|
* @returns {Promise<string>} Claude's response
|
|
99
126
|
* @throws {ClaudeClientError} If execution fails or times out
|
|
100
127
|
*/
|
|
101
|
-
const executeClaude = (prompt, { timeout = 120000 } = {}) => {
|
|
128
|
+
const executeClaude = (prompt, { timeout = 120000, allowedTools = [] } = {}) => {
|
|
102
129
|
return new Promise((resolve, reject) => {
|
|
103
130
|
// Get platform-specific command
|
|
104
131
|
const { command, args } = getClaudeCommand();
|
|
105
|
-
|
|
132
|
+
|
|
133
|
+
// Add allowed tools if specified (for MCP tools)
|
|
134
|
+
const finalArgs = [...args];
|
|
135
|
+
if (allowedTools.length > 0) {
|
|
136
|
+
// Format: --allowedTools "mcp__github__create_pull_request,mcp__github__get_file_contents"
|
|
137
|
+
finalArgs.push('--allowedTools', allowedTools.join(','));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const fullCommand = finalArgs.length > 0 ? `${command} ${finalArgs.join(' ')}` : command;
|
|
106
141
|
|
|
107
142
|
logger.debug(
|
|
108
143
|
'claude-client - executeClaude',
|
|
109
144
|
'Executing Claude CLI',
|
|
110
|
-
{ promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows() }
|
|
145
|
+
{ promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows(), allowedTools }
|
|
111
146
|
);
|
|
112
147
|
|
|
113
148
|
const startTime = Date.now();
|
|
114
149
|
|
|
115
150
|
// Why: Use spawn instead of exec to handle large prompts and responses
|
|
116
151
|
// spawn streams data, exec buffers everything in memory
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
// shell: true needed on Windows to resolve .cmd/.bat executables
|
|
153
|
+
const claude = spawn(command, finalArgs, {
|
|
154
|
+
stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr
|
|
155
|
+
shell: true // Required for Windows to find .cmd/.bat files
|
|
119
156
|
});
|
|
120
157
|
|
|
121
158
|
let stdout = '';
|
|
@@ -218,6 +255,97 @@ const executeClaude = (prompt, { timeout = 120000 } = {}) => {
|
|
|
218
255
|
});
|
|
219
256
|
};
|
|
220
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Executes Claude CLI fully interactively
|
|
260
|
+
* Why: Allows user to interact with Claude and approve MCP permissions
|
|
261
|
+
*
|
|
262
|
+
* @param {string} prompt - Prompt text to send to Claude
|
|
263
|
+
* @param {Object} options - Execution options
|
|
264
|
+
* @returns {Promise<string>} - Returns 'interactive' since we can't capture output
|
|
265
|
+
* @throws {ClaudeClientError} If execution fails
|
|
266
|
+
*/
|
|
267
|
+
const executeClaudeInteractive = (prompt, { timeout = 300000 } = {}) => {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const { command, args } = getClaudeCommand();
|
|
270
|
+
const { spawnSync } = require('child_process');
|
|
271
|
+
const fs = require('fs');
|
|
272
|
+
const path = require('path');
|
|
273
|
+
const os = require('os');
|
|
274
|
+
|
|
275
|
+
// Save prompt to temp file that Claude can read
|
|
276
|
+
const tempDir = os.tmpdir();
|
|
277
|
+
const tempFile = path.join(tempDir, `claude-pr-instructions.md`);
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
fs.writeFileSync(tempFile, prompt);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logger.error('claude-client - executeClaudeInteractive', 'Failed to write temp file', err);
|
|
283
|
+
reject(new ClaudeClientError('Failed to write prompt file', { cause: err }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logger.debug(
|
|
288
|
+
'claude-client - executeClaudeInteractive',
|
|
289
|
+
'Starting interactive Claude session',
|
|
290
|
+
{ promptLength: prompt.length, tempFile, command, args }
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log('╔══════════════════════════════════════════════════════════════════╗');
|
|
295
|
+
console.log('║ 🤖 INTERACTIVE CLAUDE SESSION ║');
|
|
296
|
+
console.log('╠══════════════════════════════════════════════════════════════════╣');
|
|
297
|
+
console.log('║ ║');
|
|
298
|
+
console.log('║ Instructions saved to: ║');
|
|
299
|
+
console.log(`║ ${tempFile.padEnd(62)}║`);
|
|
300
|
+
console.log('║ ║');
|
|
301
|
+
console.log('║ When Claude starts, tell it: ║');
|
|
302
|
+
console.log('║ "Read and execute the instructions in the file above" ║');
|
|
303
|
+
console.log('║ ║');
|
|
304
|
+
console.log('║ • Type "y" if prompted for MCP permissions ║');
|
|
305
|
+
console.log('║ • Type "/exit" when done ║');
|
|
306
|
+
console.log('║ ║');
|
|
307
|
+
console.log('╚══════════════════════════════════════════════════════════════════╝');
|
|
308
|
+
console.log('');
|
|
309
|
+
console.log('Starting Claude...');
|
|
310
|
+
console.log('');
|
|
311
|
+
|
|
312
|
+
// Run Claude fully interactively (no flags - pure interactive mode)
|
|
313
|
+
const result = spawnSync(command, args, {
|
|
314
|
+
stdio: 'inherit', // Full terminal access
|
|
315
|
+
shell: true,
|
|
316
|
+
timeout
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Clean up temp file
|
|
320
|
+
try {
|
|
321
|
+
fs.unlinkSync(tempFile);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
logger.debug('claude-client - executeClaudeInteractive', 'Temp file cleanup', { error: e.message });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (result.error) {
|
|
327
|
+
logger.error('claude-client - executeClaudeInteractive', 'Spawn error', result.error);
|
|
328
|
+
reject(new ClaudeClientError('Failed to start Claude', { cause: result.error }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (result.status === 0 || result.status === null) {
|
|
333
|
+
console.log('');
|
|
334
|
+
resolve('interactive-session-completed');
|
|
335
|
+
} else if (result.signal === 'SIGTERM') {
|
|
336
|
+
reject(new ClaudeClientError('Claude session timed out', { context: { timeout } }));
|
|
337
|
+
} else {
|
|
338
|
+
logger.error('claude-client - executeClaudeInteractive', 'Claude exited with error', {
|
|
339
|
+
status: result.status,
|
|
340
|
+
signal: result.signal
|
|
341
|
+
});
|
|
342
|
+
reject(new ClaudeClientError(`Claude exited with code ${result.status}`, {
|
|
343
|
+
context: { exitCode: result.status, signal: result.signal }
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
221
349
|
/**
|
|
222
350
|
* Extracts JSON from Claude's response
|
|
223
351
|
* Why: Claude may include markdown formatting or explanatory text around JSON
|
|
@@ -456,9 +584,13 @@ const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
|
456
584
|
export {
|
|
457
585
|
ClaudeClientError,
|
|
458
586
|
executeClaude,
|
|
587
|
+
executeClaudeInteractive,
|
|
459
588
|
extractJSON,
|
|
460
589
|
saveDebugResponse,
|
|
461
590
|
analyzeCode,
|
|
462
591
|
analyzeCodeParallel,
|
|
463
|
-
chunkArray
|
|
592
|
+
chunkArray,
|
|
593
|
+
isWindows,
|
|
594
|
+
isWSLAvailable,
|
|
595
|
+
getClaudeCommand
|
|
464
596
|
};
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Key responsibilities:
|
|
6
6
|
* - Read files with size validation
|
|
7
|
-
* - Filter SKIP-ANALYSIS patterns from code
|
|
8
7
|
* - Validate file extensions
|
|
9
8
|
* - Check file sizes
|
|
10
9
|
*
|
|
@@ -15,7 +14,6 @@
|
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import fs from 'fs/promises';
|
|
18
|
-
import fsSync from 'fs';
|
|
19
17
|
import path from 'path';
|
|
20
18
|
import logger from './logger.js';
|
|
21
19
|
|
|
@@ -120,85 +118,6 @@ const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {})
|
|
|
120
118
|
}
|
|
121
119
|
};
|
|
122
120
|
|
|
123
|
-
/**
|
|
124
|
-
* Filters SKIP_ANALYSIS patterns from code content
|
|
125
|
-
* Why: Allows developers to exclude specific code from analysis
|
|
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
|
-
*
|
|
137
|
-
* Supports two patterns:
|
|
138
|
-
* 1. Single line: // SKIP_ANALYSIS_LINE (excludes next line)
|
|
139
|
-
* 2. Block: // SKIP_ANALYSIS_BLOCK ... // SKIP_ANALYSIS_BLOCK (excludes block)
|
|
140
|
-
*
|
|
141
|
-
* @param {string} content - File content to filter
|
|
142
|
-
* @returns {string} Filtered content
|
|
143
|
-
*/
|
|
144
|
-
const filterSkipAnalysis = (content) => {
|
|
145
|
-
logger.debug(
|
|
146
|
-
'file-operations - filterSkipAnalysis',
|
|
147
|
-
'Filtering SKIP_ANALYSIS patterns',
|
|
148
|
-
{ originalLength: content.length }
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const lines = content.split('\n');
|
|
152
|
-
let skipNext = false;
|
|
153
|
-
let inSkipBlock = false;
|
|
154
|
-
|
|
155
|
-
// Why: Use map instead of filter to preserve line numbers for error reporting
|
|
156
|
-
// Empty lines maintain original line number mapping
|
|
157
|
-
const filteredLines = lines.map((line) => {
|
|
158
|
-
// IMPORTANT: Check specific pattern first (SKIP_ANALYSIS_BLOCK)
|
|
159
|
-
// before general pattern (SKIP_ANALYSIS_LINE) to avoid substring match issues
|
|
160
|
-
|
|
161
|
-
// Detect SKIP_ANALYSIS_BLOCK (toggle block state)
|
|
162
|
-
if (line.includes('// SKIP_ANALYSIS_BLOCK')) {
|
|
163
|
-
inSkipBlock = !inSkipBlock;
|
|
164
|
-
return ''; // Preserve line number
|
|
165
|
-
}
|
|
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
|
-
|
|
173
|
-
// Skip lines inside block
|
|
174
|
-
if (inSkipBlock) {
|
|
175
|
-
return '';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Skip next line after single SKIP_ANALYSIS_LINE
|
|
179
|
-
if (skipNext) {
|
|
180
|
-
skipNext = false;
|
|
181
|
-
return '';
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return line;
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const filtered = filteredLines.join('\n');
|
|
188
|
-
|
|
189
|
-
logger.debug(
|
|
190
|
-
'file-operations - filterSkipAnalysis',
|
|
191
|
-
'Filtering complete',
|
|
192
|
-
{
|
|
193
|
-
originalLength: content.length,
|
|
194
|
-
filteredLength: filtered.length,
|
|
195
|
-
linesRemoved: lines.length - filteredLines.filter(l => l !== '').length
|
|
196
|
-
}
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
return filtered;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
121
|
/**
|
|
203
122
|
* Validates file extension against allowed list
|
|
204
123
|
* Why: Pre-commit hook should only analyze specific file types
|
|
@@ -361,32 +280,11 @@ const filterFiles = async (files, { maxSize = 100000, extensions = [] } = {}) =>
|
|
|
361
280
|
return fileMetadata;
|
|
362
281
|
};
|
|
363
282
|
|
|
364
|
-
/**
|
|
365
|
-
* Reads multiple files in parallel with filtering
|
|
366
|
-
* Why: Efficiently loads file contents for analysis
|
|
367
|
-
*
|
|
368
|
-
* @param {Array<string>} files - Array of file paths
|
|
369
|
-
* @param {Object} options - Read options
|
|
370
|
-
* @param {number} options.maxSize - Max file size
|
|
371
|
-
* @param {boolean} options.applySkipFilter - Apply SKIP-ANALYSIS filtering
|
|
372
|
-
* @returns {Promise<Array<Object>>} Files with content
|
|
373
|
-
*
|
|
374
|
-
* Returned object structure:
|
|
375
|
-
* [
|
|
376
|
-
* {
|
|
377
|
-
* path: string, // File path
|
|
378
|
-
* content: string, // File content (filtered if requested)
|
|
379
|
-
* size: number, // Original size in bytes
|
|
380
|
-
* error: Error // Error if read failed
|
|
381
|
-
* }
|
|
382
|
-
* ]
|
|
383
|
-
*/
|
|
384
283
|
export {
|
|
385
284
|
FileOperationError,
|
|
386
285
|
getFileSize,
|
|
387
286
|
fileExists,
|
|
388
287
|
readFile,
|
|
389
|
-
filterSkipAnalysis,
|
|
390
288
|
hasAllowedExtension,
|
|
391
289
|
filterFiles
|
|
392
290
|
};
|