/** * Cross-platform utility functions for Claude Code hooks and scripts * Works on Windows, macOS, and Linux */ const fs = require('fs'); const path = require('path'); const os = require('os'); const { execSync, spawnSync } = require('child_process'); // Platform detection const isWindows = process.platform === 'win32'; const isMacOS = process.platform === 'darwin'; const isLinux = process.platform === 'linux'; /** * Get the user's home directory (cross-platform) */ function getHomeDir() { return os.homedir(); } /** * Get the Claude config directory */ function getClaudeDir() { return path.join(getHomeDir(), '.claude'); } /** * Get the sessions directory */ function getSessionsDir() { return path.join(getClaudeDir(), 'sessions'); } /** * Get the learned skills directory */ function getLearnedSkillsDir() { return path.join(getClaudeDir(), 'skills', 'learned'); } /** * Get the temp directory (cross-platform) */ function getTempDir() { return os.tmpdir(); } /** * Ensure a directory exists (create if not) * @param {string} dirPath - Directory path to create * @returns {string} The directory path * @throws {Error} If directory cannot be created (e.g., permission denied) */ function ensureDir(dirPath) { try { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } catch (err) { // EEXIST is fine (race condition with another process creating it) if (err.code !== 'EEXIST') { throw new Error(`Failed to create directory '${dirPath}': ${err.message}`); } } return dirPath; } /** * Get current date in YYYY-MM-DD format */ function getDateString() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Get current time in HH:MM format */ function getTimeString() { const now = new Date(); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); return `${hours}:${minutes}`; } /** * Get the git repository name */ function getGitRepoName() { const result = runCommand('git rev-parse --show-toplevel'); if (!result.success) return null; return path.basename(result.output); } /** * Get project name from git repo or current directory */ function getProjectName() { const repoName = getGitRepoName(); if (repoName) return repoName; return path.basename(process.cwd()) || null; } /** * Get short session ID from CLAUDE_SESSION_ID environment variable * Returns last 8 characters, falls back to project name then 'default' */ function getSessionIdShort(fallback = 'default') { const sessionId = process.env.CLAUDE_SESSION_ID; if (sessionId && sessionId.length > 0) { return sessionId.slice(-8); } return getProjectName() || fallback; } /** * Get current datetime in YYYY-MM-DD HH:MM:SS format */ function getDateTimeString() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } /** * Find files matching a pattern in a directory (cross-platform alternative to find) * @param {string} dir - Directory to search * @param {string} pattern - File pattern (e.g., "*.tmp", "*.md") * @param {object} options - Options { maxAge: days, recursive: boolean } */ function findFiles(dir, pattern, options = {}) { if (!dir || typeof dir !== 'string') return []; if (!pattern || typeof pattern !== 'string') return []; const { maxAge = null, recursive = false } = options; const results = []; if (!fs.existsSync(dir)) { return results; } const regexPattern = pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`); function searchDir(currentDir) { try { const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isFile() && regex.test(entry.name)) { let stats; try { stats = fs.statSync(fullPath); } catch { continue; // File deleted between readdir and stat } if (maxAge !== null) { const ageInDays = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24); if (ageInDays <= maxAge) { results.push({ path: fullPath, mtime: stats.mtimeMs }); } } else { results.push({ path: fullPath, mtime: stats.mtimeMs }); } } else if (entry.isDirectory() && recursive) { searchDir(fullPath); } } } catch (_err) { // Ignore permission errors } } searchDir(dir); // Sort by modification time (newest first) results.sort((a, b) => b.mtime - a.mtime); return results; } /** * Read JSON from stdin (for hook input) * @param {object} options - Options * @param {number} options.timeoutMs - Timeout in milliseconds (default: 5000). * Prevents hooks from hanging indefinitely if stdin never closes. * @returns {Promise} Parsed JSON object, or empty object if stdin is empty */ async function readStdinJson(options = {}) { const { timeoutMs = 5000, maxSize = 1024 * 1024 } = options; return new Promise((resolve, reject) => { let data = ''; let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; // Resolve with whatever we have so far rather than hanging try { resolve(data.trim() ? JSON.parse(data) : {}); } catch { resolve({}); } } }, timeoutMs); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (data.length < maxSize) { data += chunk; } }); process.stdin.on('end', () => { if (settled) return; settled = true; clearTimeout(timer); try { resolve(data.trim() ? JSON.parse(data) : {}); } catch (err) { reject(err); } }); process.stdin.on('error', err => { if (settled) return; settled = true; clearTimeout(timer); reject(err); }); }); } /** * Log to stderr (visible to user in Claude Code) */ function log(message) { console.error(message); } /** * Output to stdout (returned to Claude) */ function output(data) { if (typeof data === 'object') { console.log(JSON.stringify(data)); } else { console.log(data); } } /** * Read a text file safely */ function readFile(filePath) { try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; } } /** * Write a text file */ function writeFile(filePath, content) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content, 'utf8'); } /** * Append to a text file */ function appendFile(filePath, content) { ensureDir(path.dirname(filePath)); fs.appendFileSync(filePath, content, 'utf8'); } /** * Check if a command exists in PATH * Uses execFileSync to prevent command injection */ function commandExists(cmd) { // Validate command name - only allow alphanumeric, dash, underscore, dot if (!/^[a-zA-Z0-9_.-]+$/.test(cmd)) { return false; } try { if (isWindows) { // Use spawnSync to avoid shell interpolation const result = spawnSync('where', [cmd], { stdio: 'pipe' }); return result.status === 0; } else { const result = spawnSync('which', [cmd], { stdio: 'pipe' }); return result.status === 0; } } catch { return false; } } /** * Run a command and return output * * SECURITY NOTE: This function executes shell commands. Only use with * trusted, hardcoded commands. Never pass user-controlled input directly. * For user input, use spawnSync with argument arrays instead. * * @param {string} cmd - Command to execute (should be trusted/hardcoded) * @param {object} options - execSync options */ function runCommand(cmd, options = {}) { try { const result = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...options }); return { success: true, output: result.trim() }; } catch (err) { return { success: false, output: err.stderr || err.message }; } } /** * Check if current directory is a git repository */ function isGitRepo() { return runCommand('git rev-parse --git-dir').success; } /** * Get git modified files, optionally filtered by regex patterns * @param {string[]} patterns - Array of regex pattern strings to filter files. * Invalid patterns are silently skipped. * @returns {string[]} Array of modified file paths */ function getGitModifiedFiles(patterns = []) { if (!isGitRepo()) return []; const result = runCommand('git diff --name-only HEAD'); if (!result.success) return []; let files = result.output.split('\n').filter(Boolean); if (patterns.length > 0) { // Pre-compile patterns, skipping invalid ones const compiled = []; for (const pattern of patterns) { if (typeof pattern !== 'string' || pattern.length === 0) continue; try { compiled.push(new RegExp(pattern)); } catch { // Skip invalid regex patterns } } if (compiled.length > 0) { files = files.filter(file => compiled.some(regex => regex.test(file))); } } return files; } /** * Replace text in a file (cross-platform sed alternative) */ function replaceInFile(filePath, search, replace) { const content = readFile(filePath); if (content === null) return false; try { const newContent = content.replace(search, replace); writeFile(filePath, newContent); return true; } catch (err) { log(`[Utils] replaceInFile failed for ${filePath}: ${err.message}`); return false; } } /** * Count occurrences of a pattern in a file * @param {string} filePath - Path to the file * @param {string|RegExp} pattern - Pattern to count. Strings are treated as * global regex patterns. RegExp instances are used as-is but the global * flag is enforced to ensure correct counting. * @returns {number} Number of matches found */ function countInFile(filePath, pattern) { const content = readFile(filePath); if (content === null) return 0; let regex; try { if (pattern instanceof RegExp) { // Ensure global flag is set for correct counting regex = pattern.global ? pattern : new RegExp(pattern.source, pattern.flags + 'g'); } else if (typeof pattern === 'string') { regex = new RegExp(pattern, 'g'); } else { return 0; } } catch { return 0; // Invalid regex pattern } const matches = content.match(regex); return matches ? matches.length : 0; } /** * Search for pattern in file and return matching lines with line numbers */ function grepFile(filePath, pattern) { const content = readFile(filePath); if (content === null) return []; let regex; try { regex = pattern instanceof RegExp ? pattern : new RegExp(pattern); } catch { return []; // Invalid regex pattern } const lines = content.split('\n'); const results = []; lines.forEach((line, index) => { if (regex.test(line)) { results.push({ lineNumber: index + 1, content: line }); } }); return results; } module.exports = { // Platform info isWindows, isMacOS, isLinux, // Directories getHomeDir, getClaudeDir, getSessionsDir, getLearnedSkillsDir, getTempDir, ensureDir, // Date/Time getDateString, getTimeString, getDateTimeString, // Session/Project getSessionIdShort, getGitRepoName, getProjectName, // File operations findFiles, readFile, writeFile, appendFile, replaceInFile, countInFile, grepFile, // Hook I/O readStdinJson, log, output, // System commandExists, runCommand, isGitRepo, getGitModifiedFiles };