claude-git-hooks 1.5.5 → 2.1.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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * File: claude-client.js
3
+ * Purpose: Interface with Claude CLI for code analysis
4
+ *
5
+ * Key responsibilities:
6
+ * - Execute Claude CLI with prompts
7
+ * - Parse JSON responses from Claude
8
+ * - Handle errors and timeouts
9
+ * - Optional debug output
10
+ *
11
+ * Dependencies:
12
+ * - child_process: For executing Claude CLI
13
+ * - fs/promises: For debug file writing
14
+ * - logger: Debug and error logging
15
+ */
16
+
17
+ import { spawn, execSync } from 'child_process';
18
+ import fs from 'fs/promises';
19
+ import os from 'os';
20
+ import logger from './logger.js';
21
+
22
+ /**
23
+ * Custom error for Claude client failures
24
+ */
25
+ class ClaudeClientError extends Error {
26
+ constructor(message, { cause, output, context } = {}) {
27
+ super(message);
28
+ this.name = 'ClaudeClientError';
29
+ this.cause = cause;
30
+ this.output = output;
31
+ this.context = context;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Detect if running on Windows
37
+ * Why: Need to use 'wsl claude' instead of 'claude' on Windows
38
+ */
39
+ const isWindows = () => {
40
+ return os.platform() === 'win32' || process.env.OS === 'Windows_NT';
41
+ };
42
+
43
+ /**
44
+ * Check if WSL is available on Windows
45
+ * Why: Windows users need WSL to run Claude CLI, verify it exists before attempting
46
+ *
47
+ * @returns {boolean} True if WSL is available
48
+ */
49
+ const isWSLAvailable = () => {
50
+ if (!isWindows()) {
51
+ return true; // Not Windows, WSL check not needed
52
+ }
53
+
54
+ try {
55
+ // Try to run wsl --version to check if WSL is installed
56
+ execSync('wsl --version', { stdio: 'ignore', timeout: 3000 });
57
+ return true;
58
+ } catch (error) {
59
+ logger.debug('claude-client - isWSLAvailable', 'WSL not available', error);
60
+ return false;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * Get Claude command configuration for current platform
66
+ * Why: On Windows, Claude CLI runs in WSL, so we need 'wsl' as command and 'claude' as arg
67
+ *
68
+ * @returns {Object} { command, args } - Command and base arguments
69
+ * @throws {ClaudeClientError} If Windows without WSL
70
+ */
71
+ const getClaudeCommand = () => {
72
+ if (isWindows()) {
73
+ if (!isWSLAvailable()) {
74
+ throw new ClaudeClientError('WSL is required on Windows but not found', {
75
+ context: {
76
+ platform: 'Windows',
77
+ suggestion: 'Install WSL: https://docs.microsoft.com/en-us/windows/wsl/install'
78
+ }
79
+ });
80
+ }
81
+ return { command: 'wsl', args: ['claude'] };
82
+ }
83
+ return { command: 'claude', args: [] };
84
+ };
85
+
86
+ /**
87
+ * Executes Claude CLI with a prompt
88
+ * Why: Centralized Claude CLI execution with error handling and timeout
89
+ * Why platform detection: On Windows, must use 'wsl claude' to access Claude in WSL
90
+ *
91
+ * @param {string} prompt - Prompt text to send to Claude
92
+ * @param {Object} options - Execution options
93
+ * @param {number} options.timeout - Timeout in milliseconds (default: 120000 = 2 minutes)
94
+ * @returns {Promise<string>} Claude's response
95
+ * @throws {ClaudeClientError} If execution fails or times out
96
+ */
97
+ const executeClaude = (prompt, { timeout = 120000 } = {}) => {
98
+ return new Promise((resolve, reject) => {
99
+ // Get platform-specific command
100
+ const { command, args } = getClaudeCommand();
101
+ const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
102
+
103
+ logger.debug(
104
+ 'claude-client - executeClaude',
105
+ 'Executing Claude CLI',
106
+ { promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows() }
107
+ );
108
+
109
+ const startTime = Date.now();
110
+
111
+ // Why: Use spawn instead of exec to handle large prompts and responses
112
+ // spawn streams data, exec buffers everything in memory
113
+ const claude = spawn(command, args, {
114
+ stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
115
+ });
116
+
117
+ let stdout = '';
118
+ let stderr = '';
119
+
120
+ // Collect stdout
121
+ claude.stdout.on('data', (data) => {
122
+ stdout += data.toString();
123
+ });
124
+
125
+ // Collect stderr
126
+ claude.stderr.on('data', (data) => {
127
+ stderr += data.toString();
128
+ });
129
+
130
+ // Handle process completion
131
+ claude.on('close', (code) => {
132
+ const duration = Date.now() - startTime;
133
+
134
+ if (code === 0) {
135
+ logger.debug(
136
+ 'claude-client - executeClaude',
137
+ 'Claude CLI execution successful',
138
+ { duration, outputLength: stdout.length }
139
+ );
140
+ resolve(stdout);
141
+ } else {
142
+ logger.error(
143
+ 'claude-client - executeClaude',
144
+ `Claude CLI failed with exit code ${code}`,
145
+ new ClaudeClientError('Claude CLI execution failed', {
146
+ output: { stdout, stderr },
147
+ context: { exitCode: code, duration }
148
+ })
149
+ );
150
+
151
+ reject(new ClaudeClientError('Claude CLI execution failed', {
152
+ output: { stdout, stderr },
153
+ context: { exitCode: code, duration }
154
+ }));
155
+ }
156
+ });
157
+
158
+ // Handle errors
159
+ claude.on('error', (error) => {
160
+ logger.error(
161
+ 'claude-client - executeClaude',
162
+ 'Failed to spawn Claude CLI process',
163
+ error
164
+ );
165
+
166
+ reject(new ClaudeClientError('Failed to spawn Claude CLI', {
167
+ cause: error,
168
+ context: { command, args }
169
+ }));
170
+ });
171
+
172
+ // Set up timeout
173
+ const timeoutId = setTimeout(() => {
174
+ claude.kill();
175
+ logger.error(
176
+ 'claude-client - executeClaude',
177
+ 'Claude CLI execution timed out',
178
+ new ClaudeClientError('Claude CLI timeout', {
179
+ context: { timeout }
180
+ })
181
+ );
182
+
183
+ reject(new ClaudeClientError('Claude CLI execution timed out', {
184
+ context: { timeout }
185
+ }));
186
+ }, timeout);
187
+
188
+ // Clear timeout if process completes
189
+ claude.on('close', () => clearTimeout(timeoutId));
190
+
191
+ // Write prompt to stdin
192
+ // Why: Claude CLI reads prompt from stdin, not command arguments
193
+ try {
194
+ claude.stdin.write(prompt);
195
+ claude.stdin.end();
196
+ } catch (error) {
197
+ logger.error(
198
+ 'claude-client - executeClaude',
199
+ 'Failed to write prompt to Claude CLI stdin',
200
+ error
201
+ );
202
+
203
+ reject(new ClaudeClientError('Failed to write prompt', {
204
+ cause: error
205
+ }));
206
+ }
207
+ });
208
+ };
209
+
210
+ /**
211
+ * Extracts JSON from Claude's response
212
+ * Why: Claude may include markdown formatting or explanatory text around JSON
213
+ *
214
+ * @param {string} response - Raw response from Claude
215
+ * @returns {Object} Parsed JSON object
216
+ * @throws {ClaudeClientError} If no valid JSON found
217
+ */
218
+ const extractJSON = (response) => {
219
+ logger.debug(
220
+ 'claude-client - extractJSON',
221
+ 'Extracting JSON from response',
222
+ { responseLength: response.length }
223
+ );
224
+
225
+ // Why: Try multiple patterns to find JSON
226
+ // Pattern 1: JSON in markdown code block
227
+ const markdownMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
228
+ if (markdownMatch) {
229
+ try {
230
+ const json = JSON.parse(markdownMatch[1]);
231
+ logger.debug('claude-client - extractJSON', 'JSON extracted from markdown block');
232
+ return json;
233
+ } catch (error) {
234
+ // Continue to next pattern
235
+ }
236
+ }
237
+
238
+ // Pattern 2: JSON object (curly braces)
239
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
240
+ if (jsonMatch) {
241
+ try {
242
+ const json = JSON.parse(jsonMatch[0]);
243
+ logger.debug('claude-client - extractJSON', 'JSON extracted from response body');
244
+ return json;
245
+ } catch (error) {
246
+ // Continue to next pattern
247
+ }
248
+ }
249
+
250
+ // Pattern 3: Extract lines starting with { until matching }
251
+ const lines = response.split('\n');
252
+ let jsonStartIndex = -1;
253
+ let braceCount = 0;
254
+ let jsonLines = [];
255
+
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const line = lines[i].trim();
258
+
259
+ if (jsonStartIndex === -1 && line.startsWith('{')) {
260
+ jsonStartIndex = i;
261
+ braceCount = (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
262
+ jsonLines.push(line);
263
+ } else if (jsonStartIndex !== -1) {
264
+ jsonLines.push(line);
265
+ braceCount += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
266
+
267
+ if (braceCount === 0) {
268
+ const jsonText = jsonLines.join('\n');
269
+ try {
270
+ const json = JSON.parse(jsonText);
271
+ logger.debug('claude-client - extractJSON', 'JSON extracted using line matching');
272
+ return json;
273
+ } catch (error) {
274
+ // Try next occurrence
275
+ jsonStartIndex = -1;
276
+ jsonLines = [];
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ // No valid JSON found
283
+ logger.error(
284
+ 'claude-client - extractJSON',
285
+ 'No valid JSON found in response',
286
+ new ClaudeClientError('No valid JSON in response', {
287
+ output: response.substring(0, 500) // First 500 chars for debugging
288
+ })
289
+ );
290
+
291
+ throw new ClaudeClientError('No valid JSON found in Claude response', {
292
+ output: response.substring(0, 500)
293
+ });
294
+ };
295
+
296
+ /**
297
+ * Saves response to debug file
298
+ * Why: Helps troubleshoot issues with Claude responses
299
+ *
300
+ * @param {string} response - Raw response to save
301
+ * @param {string} filename - Debug filename (default: 'debug-claude-response.json')
302
+ */
303
+ const saveDebugResponse = async (response, filename = 'debug-claude-response.json') => {
304
+ try {
305
+ await fs.writeFile(filename, response, 'utf8');
306
+ logger.debug(
307
+ 'claude-client - saveDebugResponse',
308
+ `Debug response saved to ${filename}`
309
+ );
310
+ } catch (error) {
311
+ logger.error(
312
+ 'claude-client - saveDebugResponse',
313
+ 'Failed to save debug response',
314
+ error
315
+ );
316
+ }
317
+ };
318
+
319
+ /**
320
+ * Analyzes code using Claude CLI
321
+ * Why: High-level interface that handles execution, parsing, and debug
322
+ *
323
+ * @param {string} prompt - Analysis prompt
324
+ * @param {Object} options - Analysis options
325
+ * @param {number} options.timeout - Timeout in milliseconds
326
+ * @param {boolean} options.saveDebug - Save response to debug file (default: from DEBUG env)
327
+ * @returns {Promise<Object>} Parsed analysis result
328
+ * @throws {ClaudeClientError} If analysis fails
329
+ */
330
+ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = process.env.DEBUG === 'true' } = {}) => {
331
+ logger.debug(
332
+ 'claude-client - analyzeCode',
333
+ 'Starting code analysis',
334
+ { promptLength: prompt.length, timeout, saveDebug }
335
+ );
336
+
337
+ try {
338
+ // Execute Claude CLI
339
+ const response = await executeClaude(prompt, { timeout });
340
+
341
+ // Save debug if requested
342
+ if (saveDebug) {
343
+ await saveDebugResponse(response);
344
+ }
345
+
346
+ // Extract and parse JSON
347
+ const result = extractJSON(response);
348
+
349
+ logger.debug(
350
+ 'claude-client - analyzeCode',
351
+ 'Analysis complete',
352
+ {
353
+ hasApproved: 'approved' in result,
354
+ hasQualityGate: 'QUALITY_GATE' in result,
355
+ blockingIssuesCount: result.blockingIssues?.length ?? 0
356
+ }
357
+ );
358
+
359
+ return result;
360
+
361
+ } catch (error) {
362
+ logger.error('claude-client - analyzeCode', 'Code analysis failed', error);
363
+ throw error;
364
+ }
365
+ };
366
+
367
+ export {
368
+ ClaudeClientError,
369
+ executeClaude,
370
+ extractJSON,
371
+ saveDebugResponse,
372
+ analyzeCode
373
+ };