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.
@@ -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
- // Get staged files
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
- const message = formatCommitMessage(response);
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, Claude CLI runs in WSL, so we need 'wsl' as command and 'claude' as arg
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 Windows without WSL
73
+ * @throws {ClaudeClientError} If Claude not available on any method
74
74
  */
75
75
  const getClaudeCommand = () => {
76
76
  if (isWindows()) {
77
- if (!isWSLAvailable()) {
78
- throw new ClaudeClientError('WSL is required on Windows but not found', {
79
- context: {
80
- platform: 'Windows',
81
- suggestion: 'Install WSL: https://docs.microsoft.com/en-us/windows/wsl/install'
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
- const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
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
- const claude = spawn(command, args, {
118
- stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
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
  };