@buger/probe-chat
Version:
CLI and web interface for Probe code search (formerly @buger/probe-web and @buger/probe-chat)
767 lines (652 loc) • 26 kB
JavaScript
/**
* Claude Code SDK backend implementation
* @module ClaudeCodeBackend
*/
import BaseBackend from './BaseBackend.js';
import { BackendError, ErrorTypes, ProgressTracker, FileChangeParser, TokenEstimator } from '../core/utils.js';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { TIMEOUTS, getDefaultTimeoutMs } from '../core/timeouts.js';
const execPromise = promisify(exec);
/**
* Claude Code SDK implementation backend
* @class
* @extends BaseBackend
*/
class ClaudeCodeBackend extends BaseBackend {
constructor() {
super('claude-code', '1.0.0');
this.config = null;
}
/**
* @override
*/
async initialize(config) {
this.config = {
apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
model: config.model || 'claude-3-5-sonnet-20241022',
baseUrl: config.baseUrl,
timeout: config.timeout || getDefaultTimeoutMs(), // Use centralized default (20 minutes)
maxTokens: config.maxTokens || 8000,
temperature: config.temperature || 0.3,
systemPrompt: config.systemPrompt,
tools: config.tools || ['edit', 'search', 'bash'],
maxTurns: config.maxTurns || 100,
...config
};
try {
// Claude Code backend only uses CLI interface
this.log('debug', 'Using Claude Code CLI interface');
// Validate configuration
await this.validateConfiguration();
// Test connection/availability
const available = await this.isAvailable();
if (!available) {
throw new Error('Claude Code is not available');
}
this.initialized = true;
} catch (error) {
throw new BackendError(
`Failed to initialize Claude Code backend: ${error.message}`,
ErrorTypes.INITIALIZATION_FAILED,
'CLAUDE_CODE_INIT_FAILED',
{ originalError: error }
);
}
}
/**
* @override
*/
async isAvailable() {
if (!this.config.apiKey) {
this.log('warn', 'No API key configured');
return false;
}
try {
let claudeCommand = null;
// Method 1: Try direct execution with claude --version
try {
await execPromise('claude --version', { timeout: TIMEOUTS.VERSION_CHECK });
claudeCommand = 'claude';
this.log('debug', 'Claude found in PATH via direct execution');
} catch (directError) {
this.log('debug', 'Claude not directly executable from PATH', { error: directError.message });
}
// Method 2: Check npm global installation and find the binary
if (!claudeCommand) {
try {
const { stdout } = await execPromise('npm list -g @anthropic-ai/claude-code --depth=0', { timeout: TIMEOUTS.VERSION_CHECK });
if (stdout.includes('@anthropic-ai/claude-code')) {
// Get npm global bin directory
const { stdout: binPath } = await execPromise('npm bin -g', { timeout: TIMEOUTS.VERSION_CHECK });
const npmBinDir = binPath.trim();
// Build the claude command path
const isWindows = process.platform === 'win32';
const claudeBinary = isWindows ? 'claude.cmd' : 'claude';
const claudePath = path.join(npmBinDir, claudeBinary);
// Test if we can execute it
try {
await execPromise(`"${claudePath}" --version`, { timeout: TIMEOUTS.VERSION_CHECK });
claudeCommand = claudePath;
// Update PATH for this process to include npm global bin
const pathSeparator = isWindows ? ';' : ':';
process.env.PATH = `${npmBinDir}${pathSeparator}${process.env.PATH}`;
this.log('debug', `Claude found at ${claudePath}, added ${npmBinDir} to PATH`);
} catch (execError) {
this.log('debug', `Failed to execute claude at ${claudePath}`, { error: execError.message });
}
}
} catch (npmError) {
this.log('debug', 'Failed to check npm global packages', { error: npmError.message });
}
}
// Method 3: Try WSL on Windows
if (!claudeCommand && process.platform === 'win32') {
try {
// Check if WSL is available and claude is installed there
const { stdout: wslCheck } = await execPromise('wsl --list', { timeout: TIMEOUTS.WSL_CHECK });
if (wslCheck) {
this.log('debug', 'WSL detected, checking for claude in WSL');
try {
// Try to run claude through WSL
await execPromise('wsl claude --version', { timeout: TIMEOUTS.VERSION_CHECK });
claudeCommand = 'wsl claude';
this.log('debug', 'Claude found in WSL');
} catch (wslClaudeError) {
this.log('debug', 'Claude not found in WSL', { error: wslClaudeError.message });
// Try common WSL paths
const wslPaths = [
'wsl /usr/local/bin/claude',
'wsl ~/.npm-global/bin/claude',
'wsl ~/.local/bin/claude',
'wsl ~/node_modules/.bin/claude'
];
for (const wslPath of wslPaths) {
try {
await execPromise(`${wslPath} --version`, { timeout: TIMEOUTS.WSL_CHECK });
claudeCommand = wslPath;
this.log('debug', `Claude found in WSL at: ${wslPath}`);
break;
} catch (e) {
// Continue searching
}
}
}
}
} catch (wslError) {
this.log('debug', 'WSL not available or accessible', { error: wslError.message });
}
}
// Method 4: Try to find claude in common locations
if (!claudeCommand) {
const isWindows = process.platform === 'win32';
const homeDir = process.env[isWindows ? 'USERPROFILE' : 'HOME'];
const claudeBinary = isWindows ? 'claude.cmd' : 'claude';
// Common npm global locations
const commonPaths = [
// Windows paths
isWindows && path.join(process.env.APPDATA || '', 'npm', claudeBinary),
isWindows && path.join('C:', 'Program Files', 'nodejs', claudeBinary),
// Unix-like paths
!isWindows && path.join('/usr/local/bin', claudeBinary),
!isWindows && path.join(homeDir, '.npm-global', 'bin', claudeBinary),
!isWindows && path.join(homeDir, '.local', 'bin', claudeBinary),
// Cross-platform home directory paths
path.join(homeDir, 'node_modules', '.bin', claudeBinary),
].filter(Boolean);
for (const claudePath of commonPaths) {
try {
await execPromise(`"${claudePath}" --version`, { timeout: TIMEOUTS.WSL_CHECK });
claudeCommand = claudePath;
this.log('debug', `Claude found at ${claudePath}`);
break;
} catch (e) {
// Continue searching
}
}
}
if (!claudeCommand) {
this.log('warn', 'Claude Code CLI not found. Please install with: npm install -g @anthropic-ai/claude-code (or in WSL on Windows)');
return false;
}
// Store the command for later use
this.claudeCommand = claudeCommand;
// Just verify the API key exists (non-empty)
// Don't validate format as it can vary
if (!this.config.apiKey || this.config.apiKey.trim() === '') {
this.log('warn', 'API key is not configured');
return false;
}
return true;
} catch (error) {
this.log('debug', 'Availability check failed', { error: error.message });
return false;
}
}
/**
* @override
*/
getRequiredDependencies() {
return [
{
name: 'claude-code',
type: 'cli',
installCommand: 'npm install -g @anthropic-ai/claude-code',
description: 'Claude Code CLI tool'
},
{
name: 'ANTHROPIC_API_KEY',
type: 'environment',
description: 'Anthropic API key for Claude Code'
}
];
}
/**
* @override
*/
getCapabilities() {
return {
supportsLanguages: ['javascript', 'typescript', 'python', 'rust', 'go', 'java', 'c++', 'c#', 'ruby', 'php', 'swift'],
supportsStreaming: true,
supportsRollback: false,
supportsDirectFileEdit: true,
supportsPlanGeneration: true,
supportsTestGeneration: true,
maxConcurrentSessions: 5
};
}
/**
* @override
*/
getDescription() {
return 'Claude Code CLI - Advanced AI coding assistant powered by Claude';
}
/**
* @override
*/
async execute(request) {
this.checkInitialized();
const validation = this.validateRequest(request);
if (!validation.valid) {
throw new BackendError(
`Invalid request: ${validation.errors.join(', ')}`,
ErrorTypes.VALIDATION_ERROR,
'INVALID_REQUEST'
);
}
const sessionInfo = this.createSessionInfo(request.sessionId);
const progressTracker = new ProgressTracker(request.sessionId, request.callbacks?.onProgress);
this.activeSessions.set(request.sessionId, sessionInfo);
try {
progressTracker.startStep('prepare', 'Preparing Claude Code execution');
// Build the prompt
const prompt = this.buildPrompt(request);
const workingDir = request.context?.workingDirectory || process.cwd();
this.updateSessionStatus(request.sessionId, {
status: 'running',
progress: 25,
message: 'Claude Code is processing your request'
});
progressTracker.endStep();
progressTracker.startStep('execute', 'Executing with Claude Code');
// Always use CLI interface
const result = await this.executeWithCLI(prompt, workingDir, request, sessionInfo, progressTracker);
progressTracker.endStep();
this.updateSessionStatus(request.sessionId, {
status: 'completed',
progress: 100,
message: 'Implementation completed successfully'
});
return result;
} catch (error) {
this.updateSessionStatus(request.sessionId, {
status: 'failed',
message: error.message
});
if (error instanceof BackendError) {
throw error;
}
throw new BackendError(
`Claude Code execution failed: ${error.message}`,
ErrorTypes.EXECUTION_FAILED,
'CLAUDE_CODE_EXECUTION_FAILED',
{ originalError: error, sessionId: request.sessionId }
);
} finally {
this.activeSessions.delete(request.sessionId);
}
}
/**
* Validate configuration
* @private
*/
async validateConfiguration() {
if (!this.config.apiKey) {
throw new Error('API key is required. Set ANTHROPIC_API_KEY environment variable or provide apiKey in config');
}
// No format validation - API key formats can vary
// Model validation removed - model names change frequently
// Tools validation not needed since we always use --dangerously-skip-permissions
}
/**
* Build prompt for Claude Code
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {string} Formatted prompt
* @private
*/
buildPrompt(request) {
let prompt = '';
// Add context if provided
if (request.context?.additionalContext) {
prompt += `Context:\n${request.context.additionalContext}\n\n`;
}
// Add main task
prompt += `Task:\n${request.task}\n`;
// Add constraints
if (request.context?.allowedFiles && request.context.allowedFiles.length > 0) {
prompt += `\nOnly modify these files: ${request.context.allowedFiles.join(', ')}\n`;
}
if (request.context?.language) {
prompt += `\nPrimary language: ${request.context.language}\n`;
}
// Add options
if (request.options?.generateTests) {
prompt += '\nAlso generate appropriate tests for the implemented functionality.\n';
}
if (request.options?.dryRun) {
prompt += '\nThis is a dry run - describe what changes would be made without actually implementing them.\n';
}
return prompt.trim();
}
/**
* Build system prompt for Claude Code
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {string} System prompt
* @private
*/
buildSystemPrompt(request) {
if (this.config.systemPrompt) {
return this.config.systemPrompt;
}
return `You are an expert software developer assistant using Claude Code. Your task is to implement code changes based on user requirements.
Key guidelines:
- Follow best practices for the detected programming language
- Write clean, maintainable, and well-documented code
- Include error handling where appropriate
- Consider edge cases and potential issues
- Generate tests when requested or when it would be beneficial
- Make minimal, focused changes that achieve the requested functionality
- Preserve existing code style and conventions
Working directory: ${request.context?.workingDirectory || process.cwd()}
${request.context?.allowedFiles ? `Allowed files: ${request.context.allowedFiles.join(', ')}` : ''}
${request.context?.language ? `Primary language: ${request.context.language}` : ''}`;
}
/**
* Execute using CLI interface
* @private
*/
async executeWithCLI(prompt, workingDir, request, sessionInfo, progressTracker) {
const startTime = Date.now();
// Build Claude Code CLI arguments securely
const args = this.buildSecureCommandArgs(request);
// Add the prompt using -p flag (multiline strings are handled safely by spawn)
const validatedPrompt = this.validatePrompt(prompt);
args.unshift('-p', validatedPrompt);
this.log('debug', 'Executing Claude Code CLI', {
command: 'claude',
args: args.slice(0, 5), // Log first few args only for security
workingDir
});
// Always log command info to stderr for debugging (visible in all modes)
console.error(`[INFO] Claude Code execution details:`);
console.error(`[INFO] Working directory: ${workingDir}`);
console.error(`[INFO] Environment: ANTHROPIC_API_KEY=${this.config.apiKey ? '***set***' : '***not set***'}`);
console.error(`[INFO] Prompt length: ${validatedPrompt.length} characters`);
return new Promise(async (resolve, reject) => {
// Use spawn instead of exec for better security
// Use the command we found during isAvailable() check
let claudeCommand = this.claudeCommand || 'claude';
// If we don't have a stored command, try to find it again
if (!this.claudeCommand) {
try {
// Try direct execution first
await execPromise('claude --version', { timeout: TIMEOUTS.PATH_CHECK });
claudeCommand = 'claude';
} catch (e) {
const isWindows = process.platform === 'win32';
// Try WSL on Windows
if (isWindows) {
try {
await execPromise('wsl claude --version', { timeout: TIMEOUTS.WSL_CHECK });
claudeCommand = 'wsl claude';
this.log('debug', 'Using claude from WSL');
} catch (wslError) {
// Continue to npm global check
}
}
// Try to find it in npm global bin
if (claudeCommand === 'claude') {
try {
const { stdout: binPath } = await execPromise('npm bin -g', { timeout: TIMEOUTS.PATH_CHECK });
const claudeBinary = isWindows ? 'claude.cmd' : 'claude';
const potentialClaudePath = path.join(binPath.trim(), claudeBinary);
// Test if we can execute it
await execPromise(`"${potentialClaudePath}" --version`, { timeout: TIMEOUTS.PATH_CHECK });
claudeCommand = potentialClaudePath;
this.log('debug', `Using claude from npm global: ${claudeCommand}`);
} catch (npmError) {
// Fall back to 'claude' and let it fail with a clear error
this.log('warn', 'Could not find claude in npm global bin or WSL, attempting direct execution');
}
}
}
}
// Special handling for WSL commands
let spawnCommand = claudeCommand;
let spawnArgs = args;
if (claudeCommand.startsWith('wsl ')) {
// Split WSL command properly
const wslParts = claudeCommand.split(' ');
spawnCommand = wslParts[0]; // 'wsl'
spawnArgs = [...wslParts.slice(1), ...args]; // claude path + original args
}
// Log the exact spawn command to stderr (always visible)
console.error(`[INFO] Executing command: ${spawnCommand} ${spawnArgs.join(' ')}`);
console.error(`[INFO] Shell mode: ${process.platform === 'win32'}`);
const child = spawn(spawnCommand, spawnArgs, {
cwd: workingDir,
env: this.buildSecureEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32' // Use shell on Windows for .cmd files
});
sessionInfo.childProcess = child;
sessionInfo.cancel = () => {
if (child && !child.killed) {
child.kill('SIGTERM');
}
};
let output = '';
let errorOutput = '';
// No need to send prompt to stdin - it's passed via -p argument
if (child.stdin) {
child.stdin.end();
}
// Handle stdout
if (child.stdout) {
child.stdout.on('data', (data) => {
const chunk = data.toString();
output += chunk;
// Stream to stderr for visibility
process.stderr.write(chunk);
// Report progress
progressTracker.reportMessage(chunk.trim(), 'stdout');
});
}
// Handle stderr
if (child.stderr) {
child.stderr.on('data', (data) => {
const chunk = data.toString();
errorOutput += chunk;
// Stream to stderr
process.stderr.write(chunk);
// Check for errors
if (chunk.toLowerCase().includes('error')) {
progressTracker.reportMessage(chunk.trim(), 'stderr');
}
});
}
// Handle completion
child.on('close', (code) => {
const executionTime = Date.now() - startTime;
// Clear timeout
clearTimeout(timeoutId);
if (code === 0) {
// Parse changes from output
const changes = FileChangeParser.parseChanges(output, workingDir);
resolve({
success: true,
sessionId: request.sessionId,
output,
changes,
metrics: {
executionTime,
tokensUsed: TokenEstimator.estimate(prompt + output),
filesModified: changes.length,
linesChanged: 0,
exitCode: code
},
metadata: {
command: 'claude',
args: args.slice(0, 5), // Limited args for security
model: this.config.model
}
});
} else {
// Log full error details to stderr
console.error(`[ERROR] Claude Code CLI failed with exit code: ${code}`);
console.error(`[ERROR] Full command: ${claudeCommand} ${args.join(' ')}`);
console.error(`[ERROR] Working directory: ${workingDir}`);
console.error(`[ERROR] Full stdout output:`);
console.error(output || '(no stdout)');
console.error(`[ERROR] Full stderr output:`);
console.error(errorOutput || '(no stderr)');
console.error(`[ERROR] Execution time: ${Date.now() - startTime}ms`);
reject(new BackendError(
`Claude Code CLI exited with code ${code}`,
ErrorTypes.EXECUTION_FAILED,
'CLI_EXECUTION_FAILED',
{
exitCode: code,
stdout: output.substring(0, 1000),
stderr: errorOutput.substring(0, 1000)
}
));
}
});
// Handle errors
child.on('error', (error) => {
// Clear timeout
clearTimeout(timeoutId);
// Log full error details to stderr
console.error(`[ERROR] Failed to spawn Claude Code CLI process:`);
console.error(`[ERROR] Command: ${spawnCommand}`);
console.error(`[ERROR] Args: ${spawnArgs.join(' ')}`);
console.error(`[ERROR] Working directory: ${workingDir}`);
console.error(`[ERROR] Error message: ${error.message}`);
console.error(`[ERROR] Error code: ${error.code || 'unknown'}`);
console.error(`[ERROR] Error signal: ${error.signal || 'none'}`);
console.error(`[ERROR] Full error:`, error);
reject(new BackendError(
`Failed to execute Claude Code CLI: ${error.message}`,
ErrorTypes.EXECUTION_FAILED,
'CLI_SPAWN_FAILED',
{ originalError: error }
));
});
// Set timeout
const timeout = request.options?.timeout || this.config.timeout;
const timeoutId = setTimeout(() => {
if (!child.killed) {
// Log timeout details to stderr
console.error(`[ERROR] Claude Code CLI timed out after ${timeout}ms`);
console.error(`[ERROR] Command: ${spawnCommand} ${spawnArgs.join(' ')}`);
console.error(`[ERROR] Working directory: ${workingDir}`);
console.error(`[ERROR] Partial stdout output:`);
console.error(output || '(no stdout)');
console.error(`[ERROR] Partial stderr output:`);
console.error(errorOutput || '(no stderr)');
child.kill('SIGTERM');
reject(new BackendError(
`Claude Code execution timed out after ${timeout}ms`,
ErrorTypes.TIMEOUT,
'CLAUDE_CODE_TIMEOUT',
{ timeout }
));
}
}, timeout);
});
}
/**
* Build secure command arguments
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {Array<string>} Secure command arguments
* @private
*/
buildSecureCommandArgs(request) {
const args = [];
// Add max turns with validation
const maxTurns = this.validateMaxTurns(request.options?.maxTurns || this.config.maxTurns);
if (process.env.DEBUG) {
this.log('debug', 'Max turns check', {
requestMaxTurns: request.options?.maxTurns,
configMaxTurns: this.config.maxTurns,
validatedMaxTurns: maxTurns
});
}
args.push('--max-turns', maxTurns.toString());
// Model and temperature are not supported by Claude CLI
// Claude CLI uses default model and temperature settings
// Always use --dangerously-skip-permissions to avoid tool permission complexity
args.push('--dangerously-skip-permissions');
if (process.env.DEBUG) {
this.log('debug', 'Final args constructed', { args });
}
return args;
}
/**
* Build secure environment variables
* @returns {Object} Secure environment variables
* @private
*/
buildSecureEnvironment() {
const env = { ...process.env };
if (this.config.apiKey && this.isValidApiKey(this.config.apiKey)) {
env.ANTHROPIC_API_KEY = this.config.apiKey;
}
return env;
}
/**
* Validate API key format
* @param {string} apiKey - API key to validate
* @returns {boolean} True if valid format
* @private
*/
isValidApiKey(apiKey) {
// Just check if it's a non-empty string
// API key formats can vary between providers and versions
return apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
}
/**
* Validate max turns value
* @param {number} maxTurns - Max turns to validate
* @returns {number} Validated max turns value
* @private
*/
validateMaxTurns(maxTurns) {
if (typeof maxTurns !== 'number' || isNaN(maxTurns) || maxTurns < 1) {
return 100; // Default value
}
return Math.min(Math.max(Math.floor(maxTurns), 1), 1000); // Clamp between 1 and 1000
}
/**
* Validate prompt content
* @param {string} prompt - Prompt to validate
* @returns {string} Validated prompt
* @private
*/
validatePrompt(prompt) {
if (!prompt || typeof prompt !== 'string') {
throw new BackendError(
'Invalid prompt content',
ErrorTypes.VALIDATION_ERROR,
'INVALID_PROMPT'
);
}
const maxPromptLength = 100000; // 100KB limit for prompts
if (prompt.length > maxPromptLength) {
throw new BackendError(
`Prompt too long (${prompt.length} chars, max: ${maxPromptLength})`,
ErrorTypes.VALIDATION_ERROR,
'PROMPT_TOO_LONG'
);
}
// Check for control characters that could cause issues
if (this.containsControlCharacters(prompt)) {
this.log('warn', 'Prompt contains control characters, they will be filtered');
return prompt.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); // Remove most control chars but keep newlines/tabs
}
return prompt;
}
/**
* Check if string contains problematic control characters
* @param {string} str - String to check
* @returns {boolean} True if contains control characters
* @private
*/
containsControlCharacters(str) {
// Check for control characters excluding newlines and tabs
return /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(str);
}
}
export default ClaudeCodeBackend;