UNPKG

ailock

Version:

AI-Proof File Guard - Protect sensitive files from accidental AI modifications

285 lines 10.5 kB
import { existsSync } from 'fs'; import { writeFile, mkdir, chmod, readFile } from 'fs/promises'; import { homedir } from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { SecureCommandExecutor } from '../security/CommandExecutor.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * Service for managing AI tool hooks * Follows Single Responsibility Principle - only manages hook operations */ export class HooksService { SUPPORTED_TOOLS = ['claude']; HOOK_TIMEOUT = 5000; commandExecutor; constructor() { this.commandExecutor = new SecureCommandExecutor(['which', 'where']); } /** * Detect Claude Code installation * Open/Closed Principle - can be extended for other AI tools */ detectClaudeCode() { // Primary: Environment variable set automatically by Claude Code if (process.env.CLAUDE_PROJECT_DIR) { const projectSettingsPath = path.join(process.env.CLAUDE_PROJECT_DIR, '.claude/settings.json'); return { detected: true, projectDir: process.env.CLAUDE_PROJECT_DIR, settingsPath: projectSettingsPath, isProjectLevel: existsSync(projectSettingsPath) }; } // Secondary: Check for .claude directory in project const projectClaudeDir = '.claude'; if (existsSync(projectClaudeDir)) { return { detected: true, settingsPath: path.join(projectClaudeDir, 'settings.json'), isProjectLevel: true }; } // Tertiary: Check for user-level Claude Code const userClaudeDir = path.join(homedir(), '.claude'); if (existsSync(userClaudeDir)) { return { detected: true, settingsPath: path.join(userClaudeDir, 'settings.json'), isProjectLevel: false }; } return { detected: false }; } /** * Find ailock installation path * DRY - reused by both init and hooks commands */ async findAilockInstallation() { const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); // Check if we're in the ailock development directory const devAilock = path.resolve(projectDir, 'dist/index.js'); if (existsSync(devAilock)) { return `node ${devAilock}`; } // Check for local installation const localAilock = path.resolve(projectDir, 'node_modules/.bin/ailock'); if (existsSync(localAilock)) { return localAilock; } // Check for global installation try { // Try Unix-style 'which' command first if (process.platform !== 'win32') { await this.commandExecutor.executeCommand('which', ['ailock']); } else { // Use Windows 'where' command await this.commandExecutor.executeCommand('where', ['ailock']); } return 'ailock'; } catch { // Fallback to npx return 'npx @code-is-cheap/ailock'; } } /** * Get the path to the hook script * Dependency Inversion - returns path, doesn't depend on file existence */ getHookScriptPath() { return path.resolve(__dirname, '../../hooks/claude-ailock-hook.js'); } /** * Create hook configuration * Interface Segregation - returns only what's needed for hooks */ createHookConfig(hookScriptPath) { return { hooks: { PreToolUse: [ { matcher: "Write|Edit|MultiEdit|NotebookEdit", hooks: [ { type: "command", command: hookScriptPath, timeout: this.HOOK_TIMEOUT } ] } ] } }; } /** * Install Claude Code hooks * Main installation logic, follows SRP */ async installClaudeHooks(claudeInfo) { // 1. Find hook script path const hookScriptPath = this.getHookScriptPath(); // If hook doesn't exist in package, throw error if (!existsSync(hookScriptPath)) { throw new Error(`Claude Code hook script not found at: ${hookScriptPath}`); } // 2. Make hook executable try { await chmod(hookScriptPath, 0o755); } catch { // May not be necessary on all platforms } // 3. Prepare Claude Code settings const hookConfig = this.createHookConfig(hookScriptPath); // 4. Merge with existing settings or create new const settingsPath = claudeInfo.settingsPath || '.claude/settings.json'; const mergedSettings = await this.mergeSettings(settingsPath, hookConfig); // 5. Write settings await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2)); // 6. Quick verification if (!existsSync(settingsPath)) { throw new Error('Failed to write Claude Code settings'); } } /** * Merge hook configuration with existing settings * DRY - extracted from install logic for reuse */ async mergeSettings(settingsPath, hookConfig) { let existingSettings = {}; if (existsSync(settingsPath)) { try { const content = await readFile(settingsPath, 'utf-8'); existingSettings = JSON.parse(content); } catch { // Invalid JSON, will overwrite } } else { // Create directory if needed const settingsDir = path.dirname(settingsPath); if (!existsSync(settingsDir)) { await mkdir(settingsDir, { recursive: true }); } } // Merge settings (deep merge for hooks) const mergedSettings = { ...existingSettings, hooks: { ...existingSettings.hooks, PreToolUse: [ ...(existingSettings.hooks?.PreToolUse || []), ...hookConfig.hooks.PreToolUse ] } }; // Remove duplicates based on command path if (mergedSettings.hooks.PreToolUse.length > 1) { const seen = new Set(); mergedSettings.hooks.PreToolUse = mergedSettings.hooks.PreToolUse.filter((item) => { const key = item.hooks?.[0]?.command; if (key && seen.has(key)) { return false; } seen.add(key); return true; }); } return mergedSettings; } /** * Uninstall Claude Code hooks * Liskov Substitution - can be called independently */ async uninstallClaudeHooks(claudeInfo) { const settingsPath = claudeInfo.settingsPath || '.claude/settings.json'; if (!existsSync(settingsPath)) { // No settings file, nothing to uninstall return; } try { const content = await readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); if (settings.hooks?.PreToolUse) { // Filter out ailock hooks settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((item) => { const command = item.hooks?.[0]?.command; return !command || !command.includes('claude-ailock-hook'); }); // Remove empty hooks object if no hooks left if (settings.hooks.PreToolUse.length === 0) { delete settings.hooks.PreToolUse; } if (Object.keys(settings.hooks).length === 0) { delete settings.hooks; } // Write updated settings await writeFile(settingsPath, JSON.stringify(settings, null, 2)); } } catch (error) { throw new Error(`Failed to uninstall hooks: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get hook installation status * Open for extension - can add more AI tools */ async getHookStatus(tool) { if (!this.SUPPORTED_TOOLS.includes(tool)) { return { installed: false, error: `Tool '${tool}' is not supported. Supported tools: ${this.SUPPORTED_TOOLS.join(', ')}` }; } if (tool === 'claude') { const claudeInfo = this.detectClaudeCode(); if (!claudeInfo.detected) { return { installed: false, error: 'Claude Code not detected' }; } const settingsPath = claudeInfo.settingsPath || '.claude/settings.json'; if (!existsSync(settingsPath)) { return { installed: false, location: settingsPath }; } try { const content = await readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); const ailockHooks = settings.hooks?.PreToolUse?.filter((item) => { const command = item.hooks?.[0]?.command; return command && command.includes('claude-ailock-hook'); }); return { installed: ailockHooks && ailockHooks.length > 0, location: settingsPath, hookCount: ailockHooks ? ailockHooks.length : 0 }; } catch { return { installed: false, location: settingsPath, error: 'Failed to read settings file' }; } } return { installed: false }; } /** * List all available AI tools * For future extensibility */ getSupportedTools() { return this.SUPPORTED_TOOLS; } } //# sourceMappingURL=HooksService.js.map