claude-git-hooks 2.4.1 → 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 +250 -150
- package/README.md +126 -40
- package/bin/claude-hooks +436 -2
- 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 +108 -5
- 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.github.example.json +51 -0
- package/templates/presets/ai/PRE_COMMIT_GUIDELINES.md +18 -1
- package/templates/presets/ai/preset.json +37 -37
- package/templates/settings.local.example.json +4 -0
|
@@ -125,16 +125,24 @@ const getClaudeCommand = () => {
|
|
|
125
125
|
* @returns {Promise<string>} Claude's response
|
|
126
126
|
* @throws {ClaudeClientError} If execution fails or times out
|
|
127
127
|
*/
|
|
128
|
-
const executeClaude = (prompt, { timeout = 120000 } = {}) => {
|
|
128
|
+
const executeClaude = (prompt, { timeout = 120000, allowedTools = [] } = {}) => {
|
|
129
129
|
return new Promise((resolve, reject) => {
|
|
130
130
|
// Get platform-specific command
|
|
131
131
|
const { command, args } = getClaudeCommand();
|
|
132
|
-
|
|
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;
|
|
133
141
|
|
|
134
142
|
logger.debug(
|
|
135
143
|
'claude-client - executeClaude',
|
|
136
144
|
'Executing Claude CLI',
|
|
137
|
-
{ promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows() }
|
|
145
|
+
{ promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows(), allowedTools }
|
|
138
146
|
);
|
|
139
147
|
|
|
140
148
|
const startTime = Date.now();
|
|
@@ -142,7 +150,7 @@ const executeClaude = (prompt, { timeout = 120000 } = {}) => {
|
|
|
142
150
|
// Why: Use spawn instead of exec to handle large prompts and responses
|
|
143
151
|
// spawn streams data, exec buffers everything in memory
|
|
144
152
|
// shell: true needed on Windows to resolve .cmd/.bat executables
|
|
145
|
-
const claude = spawn(command,
|
|
153
|
+
const claude = spawn(command, finalArgs, {
|
|
146
154
|
stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr
|
|
147
155
|
shell: true // Required for Windows to find .cmd/.bat files
|
|
148
156
|
});
|
|
@@ -247,6 +255,97 @@ const executeClaude = (prompt, { timeout = 120000 } = {}) => {
|
|
|
247
255
|
});
|
|
248
256
|
};
|
|
249
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
|
+
|
|
250
349
|
/**
|
|
251
350
|
* Extracts JSON from Claude's response
|
|
252
351
|
* Why: Claude may include markdown formatting or explanatory text around JSON
|
|
@@ -485,9 +584,13 @@ const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
|
485
584
|
export {
|
|
486
585
|
ClaudeClientError,
|
|
487
586
|
executeClaude,
|
|
587
|
+
executeClaudeInteractive,
|
|
488
588
|
extractJSON,
|
|
489
589
|
saveDebugResponse,
|
|
490
590
|
analyzeCode,
|
|
491
591
|
analyzeCodeParallel,
|
|
492
|
-
chunkArray
|
|
592
|
+
chunkArray,
|
|
593
|
+
isWindows,
|
|
594
|
+
isWSLAvailable,
|
|
595
|
+
getClaudeCommand
|
|
493
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
|
};
|