claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
940 lines (829 loc) • 35.2 kB
JavaScript
/**
* File: claude-client.js
* Purpose: Interface with Claude CLI for code analysis
*
* Key responsibilities:
* - Execute Claude CLI with prompts
* - Parse JSON responses from Claude
* - Handle errors and timeouts
* - Optional debug output
*
* Dependencies:
* - child_process: For executing Claude CLI
* - fs/promises: For debug file writing
* - logger: Debug and error logging
* - claude-diagnostics: Error detection and formatting
*/
import { spawn, execSync } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import logger from './logger.js';
import config from '../config.js';
import { detectClaudeError, formatClaudeError, ClaudeErrorType, isRecoverableError } from './claude-diagnostics.js';
import { which } from './which-command.js';
import { recordJsonParseFailure, recordBatchSuccess, rotateTelemetry } from './telemetry.js';
/**
* Custom error for Claude client failures
*/
class ClaudeClientError extends Error {
constructor(message, { cause, output, context } = {}) {
super(message);
this.name = 'ClaudeClientError';
this.cause = cause;
this.output = output;
this.context = context;
}
}
/**
* Detect if running on Windows
* Why: Need to use 'wsl claude' instead of 'claude' on Windows
*/
const isWindows = () => os.platform() === 'win32' || process.env.OS === 'Windows_NT';
/**
* Check if WSL is available on Windows
* Why: Windows users need WSL to run Claude CLI, verify it exists before attempting
*
* @returns {boolean} True if WSL is available
*/
const isWSLAvailable = () => {
if (!isWindows()) {
return true; // Not Windows, WSL check not needed
}
try {
// Try to run wsl --version to check if WSL is installed
execSync('wsl --version', { stdio: 'ignore', timeout: config.system.wslCheckTimeout });
return true;
} catch (error) {
logger.debug('claude-client - isWSLAvailable', 'WSL not available', error);
return false;
}
};
/**
* Get Claude command configuration for current platform
* Why: On Windows, try native Claude first, then WSL as fallback
* Node 24 Fix: Uses which() to resolve absolute paths, avoiding shell: true
*
* @returns {Object} { command, args } - Command and base arguments
* @throws {ClaudeClientError} If Claude not available on any method
*/
const getClaudeCommand = () => {
if (isWindows()) {
// Try native Windows Claude first (e.g., installed via npm/scoop/choco)
// Node 24: Use which() instead of execSync to get absolute path
const nativePath = which('claude');
if (nativePath) {
logger.debug('claude-client - getClaudeCommand', 'Using native Windows Claude CLI', { path: nativePath });
return { command: nativePath, args: [] };
}
logger.debug('claude-client - getClaudeCommand', 'Native Claude not found, trying WSL');
// Fallback to WSL
if (!isWSLAvailable()) {
throw new ClaudeClientError('Claude CLI not found. Install Claude CLI natively on Windows or via WSL', {
context: {
platform: 'Windows',
suggestions: [
'Native Windows: npm install -g @anthropic-ai/claude-cli',
'WSL: wsl --install, then install Claude in WSL'
]
}
});
}
// Check if Claude is available in WSL
// Node 24: Resolve wsl.exe absolute path to avoid shell: true
const wslPath = which('wsl');
if (wslPath) {
try {
// Verify Claude exists in WSL
// Increased timeout from 5s to 15s to handle system load better
const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
execSync(`"${wslPath}" claude --version`, { stdio: 'ignore', timeout: wslCheckTimeout });
logger.debug('claude-client - getClaudeCommand', 'Using WSL Claude CLI', { wslPath });
return { command: wslPath, args: ['claude'] };
} catch (wslError) {
// Differentiate error types for accurate user feedback
const errorMsg = wslError.message || '';
const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
// Timeout: Transient system load issue
if (errorMsg.includes('ETIMEDOUT')) {
throw new ClaudeClientError('Timeout connecting to WSL - system under heavy load', {
context: {
platform: 'Windows',
wslPath,
error: 'ETIMEDOUT',
timeoutValue: wslCheckTimeout,
suggestion: 'System is busy. Wait a moment and try again, or skip analysis: git commit --no-verify'
}
});
}
// Not found: Claude CLI missing in WSL
if (errorMsg.includes('ENOENT') || errorMsg.includes('command not found')) {
throw new ClaudeClientError('Claude CLI not found in WSL', {
context: {
platform: 'Windows',
wslPath,
error: 'ENOENT',
suggestion: 'Install Claude in WSL: wsl -e bash -c "npm install -g @anthropic-ai/claude-cli"'
}
});
}
// Generic error: Other WSL issues
throw new ClaudeClientError('Failed to verify Claude CLI in WSL', {
context: {
platform: 'Windows',
wslPath,
error: errorMsg,
suggestion: 'Check WSL is functioning: wsl --version, or skip analysis: git commit --no-verify'
}
});
}
}
throw new ClaudeClientError('Claude CLI not found in Windows or WSL', {
context: {
platform: 'Windows',
suggestions: [
'Install Claude CLI: npm install -g @anthropic-ai/claude-cli',
'Or install WSL and Claude in WSL'
]
}
});
}
// Unix (Linux, macOS): Use which() to get absolute path
const claudePath = which('claude');
if (claudePath) {
logger.debug('claude-client - getClaudeCommand', 'Using Claude CLI', { path: claudePath });
return { command: claudePath, args: [] };
}
// Fallback to 'claude' if which fails (will error later if not found)
logger.debug('claude-client - getClaudeCommand', 'which() failed, using fallback', { command: 'claude' });
return { command: 'claude', args: [] };
};
/**
* Executes Claude CLI with a prompt
* Why: Centralized Claude CLI execution with error handling and timeout
* Why platform detection: On Windows, must use 'wsl claude' to access Claude in WSL
*
* @param {string} prompt - Prompt text to send to Claude
* @param {Object} options - Execution options
* @param {number} options.timeout - Timeout in milliseconds (default: 120000 = 2 minutes)
* @returns {Promise<string>} Claude's response
* @throws {ClaudeClientError} If execution fails or times out
*/
const executeClaude = (prompt, { timeout = 120000, allowedTools = [] } = {}) => new Promise((resolve, reject) => {
// Get platform-specific command
const { command, args } = getClaudeCommand();
// Add allowed tools if specified (for MCP tools)
const finalArgs = [...args];
if (allowedTools.length > 0) {
// Format: --allowedTools "mcp__github__create_pull_request,mcp__github__get_file_contents"
finalArgs.push('--allowedTools', allowedTools.join(','));
}
// CRITICAL FIX: Windows .cmd/.bat file handling
// Why: spawn() cannot execute .cmd/.bat files directly on Windows (ENOENT error)
// Solution: Wrap with cmd.exe /c when command ends with .cmd or .bat
// Impact: Only affects Windows npm-installed CLI tools, no impact on other platforms
let spawnCommand = command;
let spawnArgs = finalArgs;
if (isWindows() && (command.endsWith('.cmd') || command.endsWith('.bat'))) {
logger.debug('claude-client - executeClaude', 'Wrapping .cmd/.bat with cmd.exe', {
originalCommand: command,
originalArgs: finalArgs
});
spawnCommand = 'cmd.exe';
spawnArgs = ['/c', command, ...finalArgs];
}
const fullCommand = spawnArgs.length > 0 ? `${spawnCommand} ${spawnArgs.join(' ')}` : spawnCommand;
logger.debug(
'claude-client - executeClaude',
'Executing Claude CLI',
{ promptLength: prompt.length, timeout, command: fullCommand, isWindows: isWindows(), allowedTools }
);
const startTime = Date.now();
// Why: Use spawn instead of exec to handle large prompts and responses
// spawn streams data, exec buffers everything in memory
// Node 24 Fix: Removed shell: true to avoid DEP0190 deprecation warning
// We now use absolute paths from which(), so shell is not needed
// Windows .cmd/.bat fix: Wrapped with cmd.exe /c (see above)
const claude = spawn(spawnCommand, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr
});
let stdout = '';
let stderr = '';
// Collect stdout
claude.stdout.on('data', (data) => {
stdout += data.toString();
});
// Collect stderr
claude.stderr.on('data', (data) => {
stderr += data.toString();
});
// Handle process completion
claude.on('close', (code) => {
const elapsedTime = Date.now() - startTime;
// Check for "Execution error" even when exit code is 0
// Why: Claude CLI sometimes returns "Execution error" with exit code 0
// This occurs during API rate limiting or temporary backend issues
// IMPORTANT: Only check for EXACT match to avoid false positives
if (stdout.trim() === 'Execution error') {
const errorInfo = detectClaudeError(stdout, stderr, code);
logger.error(
'claude-client - executeClaude',
`Claude CLI returned execution error (exit ${code})`,
new ClaudeClientError(`Claude CLI error: ${errorInfo.type}`, {
output: { stdout, stderr },
context: {
exitCode: code,
elapsedTime,
timeoutValue: timeout,
errorType: errorInfo.type
}
})
);
// Merge timing info into errorInfo for formatting
const errorInfoWithTiming = {
...errorInfo,
elapsedTime,
timeoutValue: timeout
};
// Show formatted error to user
const formattedError = formatClaudeError(errorInfoWithTiming);
console.error(`\n${ formattedError }\n`);
reject(new ClaudeClientError(`Claude CLI error: ${errorInfo.type}`, {
output: { stdout, stderr },
context: {
exitCode: code,
elapsedTime,
timeoutValue: timeout,
errorInfo
}
}));
return;
}
if (code === 0) {
logger.debug(
'claude-client - executeClaude',
'Claude CLI execution successful',
{ elapsedTime, outputLength: stdout.length }
);
resolve(stdout);
} else {
// Detect specific error type
const errorInfo = detectClaudeError(stdout, stderr, code);
logger.error(
'claude-client - executeClaude',
`Claude CLI failed: ${errorInfo.type}`,
new ClaudeClientError(`Claude CLI error: ${errorInfo.type}`, {
output: { stdout, stderr },
context: {
exitCode: code,
elapsedTime,
timeoutValue: timeout,
errorType: errorInfo.type
}
})
);
// Merge timing info into errorInfo for formatting
const errorInfoWithTiming = {
...errorInfo,
elapsedTime,
timeoutValue: timeout
};
// Show formatted error to user
const formattedError = formatClaudeError(errorInfoWithTiming);
console.error(`\n${ formattedError }\n`);
reject(new ClaudeClientError(`Claude CLI error: ${errorInfo.type}`, {
output: { stdout, stderr },
context: {
exitCode: code,
elapsedTime,
timeoutValue: timeout,
errorInfo
}
}));
}
});
// Handle errors
claude.on('error', (error) => {
const elapsedTime = Date.now() - startTime;
logger.error(
'claude-client - executeClaude',
'Failed to spawn Claude CLI process',
error
);
reject(new ClaudeClientError('Failed to spawn Claude CLI', {
cause: error,
context: {
command,
args,
elapsedTime,
timeoutValue: timeout
}
}));
});
// Set up timeout
const timeoutId = setTimeout(() => {
const elapsedTime = Date.now() - startTime;
claude.kill();
logger.error(
'claude-client - executeClaude',
'Claude CLI execution timed out',
new ClaudeClientError('Claude CLI timeout', {
context: {
elapsedTime,
timeoutValue: timeout
}
})
);
reject(new ClaudeClientError('Claude CLI execution timed out', {
context: {
elapsedTime,
timeoutValue: timeout
}
}));
}, timeout);
// Clear timeout if process completes
claude.on('close', () => clearTimeout(timeoutId));
// Write prompt to stdin
// Why: Claude CLI reads prompt from stdin, not command arguments
// Handle stdin errors (e.g., EOF when process terminates unexpectedly)
// Why: write() failures can emit 'error' events asynchronously
// Common in parallel execution with large prompts
claude.stdin.on('error', (error) => {
logger.error(
'claude-client - executeClaude',
'stdin stream error (process may have terminated early)',
{
error: error.message,
code: error.code,
promptLength: prompt.length,
duration: Date.now() - startTime
}
);
reject(new ClaudeClientError('Failed to write to Claude stdin - process terminated unexpectedly', {
cause: error,
context: {
promptLength: prompt.length,
errorCode: error.code,
errorMessage: error.message,
suggestion: 'Try reducing batch size or number of files per commit'
}
}));
});
try {
claude.stdin.write(prompt);
claude.stdin.end();
} catch (error) {
logger.error(
'claude-client - executeClaude',
'Failed to write prompt to Claude CLI stdin (synchronous error)',
error
);
reject(new ClaudeClientError('Failed to write prompt', {
cause: error
}));
}
});
/**
* Executes Claude CLI fully interactively
* Why: Allows user to interact with Claude and approve MCP permissions
*
* @param {string} prompt - Prompt text to send to Claude
* @param {Object} options - Execution options
* @returns {Promise<string>} - Returns 'interactive' since we can't capture output
* @throws {ClaudeClientError} If execution fails
*/
const executeClaudeInteractive = (prompt, { timeout = 300000 } = {}) => new Promise((resolve, reject) => {
const { command, args } = getClaudeCommand();
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Save prompt to temp file that Claude can read
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, 'claude-pr-instructions.md');
try {
fs.writeFileSync(tempFile, prompt);
} catch (err) {
logger.error('claude-client - executeClaudeInteractive', 'Failed to write temp file', err);
reject(new ClaudeClientError('Failed to write prompt file', { cause: err }));
return;
}
logger.debug(
'claude-client - executeClaudeInteractive',
'Starting interactive Claude session',
{ promptLength: prompt.length, tempFile, command, args }
);
console.log('');
console.log('╔══════════════════════════════════════════════════════════════════╗');
console.log('║ 🤖 INTERACTIVE CLAUDE SESSION ║');
console.log('╠══════════════════════════════════════════════════════════════════╣');
console.log('║ ║');
console.log('║ Instructions saved to: ║');
console.log(`║ ${tempFile.padEnd(62)}║`);
console.log('║ ║');
console.log('║ When Claude starts, tell it: ║');
console.log('║ "Read and execute the instructions in the file above" ║');
console.log('║ ║');
console.log('║ • Type "y" if prompted for MCP permissions ║');
console.log('║ • Type "/exit" when done ║');
console.log('║ ║');
console.log('╚══════════════════════════════════════════════════════════════════╝');
console.log('');
console.log('Starting Claude...');
console.log('');
// Run Claude fully interactively (no flags - pure interactive mode)
const result = spawnSync(command, args, {
stdio: 'inherit', // Full terminal access
shell: true,
timeout
});
// Clean up temp file
try {
fs.unlinkSync(tempFile);
} catch (e) {
logger.debug('claude-client - executeClaudeInteractive', 'Temp file cleanup', { error: e.message });
}
if (result.error) {
logger.error('claude-client - executeClaudeInteractive', 'Spawn error', result.error);
reject(new ClaudeClientError('Failed to start Claude', { cause: result.error }));
return;
}
if (result.status === 0 || result.status === null) {
console.log('');
resolve('interactive-session-completed');
} else if (result.signal === 'SIGTERM') {
reject(new ClaudeClientError('Claude session timed out', { context: { timeout } }));
} else {
logger.error('claude-client - executeClaudeInteractive', 'Claude exited with error', {
status: result.status,
signal: result.signal
});
reject(new ClaudeClientError(`Claude exited with code ${result.status}`, {
context: { exitCode: result.status, signal: result.signal }
}));
}
});
/**
* Extracts JSON from Claude's response
* Why: Claude may include markdown formatting or explanatory text around JSON
*
* @param {string} response - Raw response from Claude
* @param {Object} telemetryContext - Context for telemetry recording (optional)
* @returns {Object} Parsed JSON object
* @throws {ClaudeClientError} If no valid JSON found
*/
const extractJSON = (response, telemetryContext = {}) => {
logger.debug(
'claude-client - extractJSON',
'Extracting JSON from response',
{ responseLength: response.length }
);
// Why: Try multiple patterns to find JSON
// Pattern 1: JSON in markdown code block
const markdownMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
if (markdownMatch) {
try {
const json = JSON.parse(markdownMatch[1]);
logger.debug('claude-client - extractJSON', 'JSON extracted from markdown block');
return json;
} catch (error) {
// Continue to next pattern
}
}
// Pattern 2: JSON object (curly braces)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const json = JSON.parse(jsonMatch[0]);
logger.debug('claude-client - extractJSON', 'JSON extracted from response body');
return json;
} catch (error) {
// Continue to next pattern
}
}
// Pattern 3: Extract lines starting with { until matching }
const lines = response.split('\n');
let jsonStartIndex = -1;
let braceCount = 0;
let jsonLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (jsonStartIndex === -1 && line.startsWith('{')) {
jsonStartIndex = i;
braceCount = (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
jsonLines.push(line);
} else if (jsonStartIndex !== -1) {
jsonLines.push(line);
braceCount += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
if (braceCount === 0) {
const jsonText = jsonLines.join('\n');
try {
const json = JSON.parse(jsonText);
logger.debug('claude-client - extractJSON', 'JSON extracted using line matching');
return json;
} catch (error) {
// Try next occurrence
jsonStartIndex = -1;
jsonLines = [];
}
}
}
}
// No valid JSON found
const responsePreview = response.substring(0, 500);
logger.error(
'claude-client - extractJSON',
'No valid JSON found in response',
new ClaudeClientError('No valid JSON in response', {
output: responsePreview
})
);
// Telemetry is now recorded by withRetry wrapper
throw new ClaudeClientError('No valid JSON found in Claude response', {
context: {
response: response,
errorInfo: {
type: 'JSON_PARSE_ERROR'
}
}
});
};
/**
* Saves prompt and response to debug file
* Why: Helps troubleshoot issues with Claude responses and verify prompts
*
* @param {string} prompt - Prompt sent to Claude
* @param {string} response - Raw response from Claude
* @param {string} filename - Debug filename (default: from config)
*/
const saveDebugResponse = async (prompt, response, filename = config.output.debugFile) => {
try {
// Ensure output directory exists
const outputDir = path.dirname(filename);
await fs.mkdir(outputDir, { recursive: true });
// Save full debug information
const debugData = {
timestamp: new Date().toISOString(),
promptLength: prompt.length,
responseLength: response.length,
prompt,
response
};
await fs.writeFile(filename, JSON.stringify(debugData, null, 2), 'utf8');
// Display batch optimization status
try {
if (prompt.includes('OPTIMIZATION')) {
console.log(`\n${ '='.repeat(70)}`);
console.log('✅ BATCH OPTIMIZATION ENABLED');
console.log('='.repeat(70));
console.log('Multi-file analysis organized for efficient processing');
console.log('Check debug file for full prompt and response details');
console.log(`${'='.repeat(70) }\n`);
}
} catch (parseError) {
// Ignore parsing errors, just skip the display
}
logger.info(`📝 Debug output saved to ${filename}`);
logger.debug(
'claude-client - saveDebugResponse',
`Debug response saved to ${filename}`
);
} catch (error) {
logger.error(
'claude-client - saveDebugResponse',
'Failed to save debug response',
error
);
}
};
/**
* Wraps an async function with retry logic for recoverable errors and telemetry tracking
* Why: Reusable retry logic for Claude CLI operations with per-attempt telemetry
*
* @param {Function} fn - Async function to execute with retry
* @param {Object} options - Retry options
* @param {number} options.maxRetries - Maximum retry attempts (default: 3)
* @param {number} options.baseRetryDelay - Base delay in ms (default: 2000)
* @param {number} options.retryCount - Current retry attempt (internal, default: 0)
* @param {string} options.operationName - Name for logging (default: 'operation')
* @param {Object} options.telemetryContext - Context for telemetry recording (optional)
* @returns {Promise<any>} Result from fn
* @throws {Error} If fn fails after all retries
*/
const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation', telemetryContext = null } = {}) => {
const retryDelay = baseRetryDelay * Math.pow(2, retryCount);
const startTime = Date.now();
try {
const result = await fn();
// Record success telemetry if context provided
if (telemetryContext) {
const duration = Date.now() - startTime;
await recordBatchSuccess({
...telemetryContext,
duration,
retryAttempt: retryCount,
totalRetries: maxRetries
}).catch(err => {
logger.debug('claude-client - withRetry', 'Failed to record success telemetry', err);
});
}
return result;
} catch (error) {
// Record failure telemetry if context provided (before retry decision)
if (telemetryContext) {
const errorType = error.context?.errorInfo?.type || error.name || 'UNKNOWN_ERROR';
const errorMessage = error.message || 'Unknown error';
await recordJsonParseFailure({
...telemetryContext,
errorType,
errorMessage,
retryAttempt: retryCount,
totalRetries: maxRetries,
responseLength: error.context?.response?.length || 0,
responsePreview: error.context?.response?.substring(0, 100) || ''
}).catch(err => {
logger.debug('claude-client - withRetry', 'Failed to record failure telemetry', err);
});
}
// Check if error is recoverable and we haven't exceeded retry limit
const hasContext = !!error.context;
const hasErrorInfo = !!error.context?.errorInfo;
const isRecoverable = hasErrorInfo ? isRecoverableError(error.context.errorInfo) : false;
const canRetry = retryCount < maxRetries;
logger.debug(
'claude-client - withRetry',
`Retry check for ${operationName}`,
{
retryCount,
maxRetries,
hasContext,
hasErrorInfo,
isRecoverable,
canRetry,
errorType: error.context?.errorInfo?.type,
errorName: error.name
}
);
const shouldRetry = canRetry && hasContext && hasErrorInfo && isRecoverable;
if (shouldRetry) {
logger.debug(
'claude-client - withRetry',
`Recoverable error detected, retrying ${operationName} in ${retryDelay}ms (attempt ${retryCount + 1}/${maxRetries})`,
{ errorType: error.context.errorInfo.type }
);
console.log(`\n⏳ Retrying in ${retryDelay / 1000} seconds due to ${error.context.errorInfo.type}...\n`);
// Wait before retry
await new Promise(resolve => setTimeout(resolve, retryDelay));
// Retry with incremented count and same telemetry context
return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName, telemetryContext });
}
// Add retry attempt to error context if not already present
if (error.context && !error.context.retryAttempt) {
error.context.retryAttempt = retryCount;
error.context.maxRetries = maxRetries;
}
logger.error('claude-client - withRetry', `${operationName} failed after retries`, error);
throw error;
}
};
/**
* Executes Claude CLI with retry logic and telemetry tracking
* Why: Provides retry capability for executeClaude calls with per-attempt telemetry
*
* @param {string} prompt - Prompt text
* @param {Object} options - Execution options
* @param {number} options.timeout - Timeout in milliseconds
* @param {Array<string>} options.allowedTools - Allowed tools for Claude
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, hook, etc.)
* @returns {Promise<string>} Claude's response
*/
const executeClaudeWithRetry = async (prompt, options = {}) => {
const { telemetryContext = {}, ...executeOptions } = options;
return withRetry(
() => executeClaude(prompt, executeOptions),
{
operationName: 'executeClaude',
telemetryContext: telemetryContext
}
);
};
/**
* Analyzes code using Claude CLI
* Why: High-level interface that handles execution, parsing, and debug
*
* @param {string} prompt - Analysis prompt
* @param {Object} options - Analysis options
* @param {number} options.timeout - Timeout in milliseconds
* @param {boolean} options.saveDebug - Save response to debug file (default: from config)
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, batchSize, etc.)
* @returns {Promise<Object>} Parsed analysis result
* @throws {ClaudeClientError} If analysis fails
*/
const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug, telemetryContext = {} } = {}) => {
const startTime = Date.now();
logger.debug(
'claude-client - analyzeCode',
'Starting code analysis',
{ promptLength: prompt.length, timeout, saveDebug }
);
// Rotate telemetry files periodically
rotateTelemetry().catch(err => {
logger.debug('claude-client - analyzeCode', 'Failed to rotate telemetry', err);
});
// Use withRetry to wrap the entire analysis flow (with telemetry tracking)
return withRetry(
async () => {
// Execute Claude CLI
const response = await executeClaude(prompt, { timeout });
// Save debug if requested
if (saveDebug) {
await saveDebugResponse(prompt, response);
}
// Extract and parse JSON
const result = extractJSON(response, telemetryContext);
const duration = Date.now() - startTime;
logger.debug(
'claude-client - analyzeCode',
'Analysis complete',
{
hasApproved: 'approved' in result,
hasQualityGate: 'QUALITY_GATE' in result,
blockingIssuesCount: result.blockingIssues?.length ?? 0,
duration
}
);
// Telemetry is now recorded by withRetry wrapper
return result;
},
{
operationName: 'analyzeCode',
telemetryContext: {
responseLength: 0, // Will be updated in withRetry
...telemetryContext
}
}
);
};
/**
* Splits array into chunks
* @param {Array} array - Array to split
* @param {number} size - Chunk size
* @returns {Array<Array>} Array of chunks
*/
const chunkArray = (array, size) => {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
};
/**
* Runs multiple analyzeCode calls in parallel
* @param {Array<string>} prompts - Array of prompts to analyze
* @param {Object} options - Same options as analyzeCode
* @param {Object} options.telemetryContext - Base telemetry context (will be augmented per batch)
* @returns {Promise<Array<Object>>} Array of results
*/
const analyzeCodeParallel = async (prompts, options = {}) => {
const startTime = Date.now();
console.log(`\n${ '='.repeat(70)}`);
console.log(`🚀 PARALLEL EXECUTION: ${prompts.length} Claude processes`);
console.log('='.repeat(70));
logger.info(`Starting parallel analysis: ${prompts.length} prompts`);
const promises = prompts.map((prompt, index) => {
console.log(` ⚡ Launching batch ${index + 1}/${prompts.length}...`);
logger.debug('claude-client - analyzeCodeParallel', `Starting batch ${index + 1}`);
// Augment telemetry context with batch info
const batchTelemetryContext = {
...(options.telemetryContext || {}),
batchIndex: index,
totalBatches: prompts.length
};
return analyzeCode(prompt, {
...options,
telemetryContext: batchTelemetryContext
});
});
console.log(' ⏳ Waiting for all batches to complete...\n');
const results = await Promise.all(promises);
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log('='.repeat(70));
console.log(`✅ PARALLEL EXECUTION COMPLETE: ${results.length} results in ${duration}s`);
console.log(`${'='.repeat(70) }\n`);
logger.info(`Parallel analysis complete: ${results.length} results in ${duration}s`);
return results;
};
export {
ClaudeClientError,
executeClaude,
executeClaudeWithRetry,
executeClaudeInteractive,
extractJSON,
saveDebugResponse,
analyzeCode,
analyzeCodeParallel,
chunkArray,
isWindows,
isWSLAvailable,
getClaudeCommand
};