UNPKG

aicm

Version:

A TypeScript CLI tool for managing AI IDE rules across different projects and teams

347 lines (346 loc) 14.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.loadHooksFromDirectory = loadHooksFromDirectory; exports.mergeHooksConfigs = mergeHooksConfigs; exports.rewriteHooksConfigToManagedDir = rewriteHooksConfigToManagedDir; exports.countHooks = countHooks; exports.dedupeHookFiles = dedupeHookFiles; exports.writeHooksToCursor = writeHooksToCursor; const node_crypto_1 = __importDefault(require("node:crypto")); const fs_extra_1 = __importDefault(require("fs-extra")); const node_path_1 = __importDefault(require("node:path")); const config_1 = require("./config"); /** * Validate that a command path points to a file within the hooks directory * Commands should be relative paths starting with ./hooks/ */ function validateHookPath(commandPath, rootDir, hooksDir) { if (!commandPath.startsWith("./") && !commandPath.startsWith("../")) { return { valid: false }; } // Resolve path relative to rootDir (where hooks.json is located) const resolvedPath = node_path_1.default.resolve(rootDir, commandPath); const relativePath = node_path_1.default.relative(hooksDir, resolvedPath); // Check if the file is within hooks directory (not using .. to escape) if (relativePath.startsWith("..") || node_path_1.default.isAbsolute(relativePath)) { return { valid: false }; } return { valid: true, relativePath }; } /** * Load all files from the hooks directory */ async function loadAllFilesFromDirectory(dir, baseDir = dir) { const files = []; const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = node_path_1.default.join(dir, entry.name); if (entry.isDirectory()) { // Recursively load files from subdirectories const subFiles = await loadAllFilesFromDirectory(fullPath, baseDir); files.push(...subFiles); } else if (entry.isFile() && entry.name !== "hooks.json") { // Skip hooks.json, collect all other files const relativePath = node_path_1.default.relative(baseDir, fullPath); files.push({ relativePath, absolutePath: fullPath }); } } return files; } /** * Load hooks configuration from a root directory * The directory should contain hooks.json (sibling to hooks/ directory) * All files in the hooks/ subdirectory are copied during installation */ async function loadHooksFromDirectory(rootDir, source, presetName) { const hooksFilePath = node_path_1.default.join(rootDir, "hooks.json"); const hooksDir = node_path_1.default.join(rootDir, "hooks"); if (!fs_extra_1.default.existsSync(hooksFilePath)) { return { config: { version: 1, hooks: {} }, files: [], }; } const content = await fs_extra_1.default.readFile(hooksFilePath, "utf8"); const hooksConfig = JSON.parse(content); // Load all files from hooks/ subdirectory const hookFiles = []; if (fs_extra_1.default.existsSync(hooksDir)) { const allFiles = await loadAllFilesFromDirectory(hooksDir); // Create a map of all files for validation const filePathMap = new Map(); for (const file of allFiles) { filePathMap.set(file.relativePath, file.absolutePath); } // Validate that all referenced commands point to files within hooks directory if (hooksConfig.hooks) { for (const hookType of Object.keys(hooksConfig.hooks)) { const hookCommands = hooksConfig.hooks[hookType]; if (hookCommands && Array.isArray(hookCommands)) { for (const hookCommand of hookCommands) { const commandPath = hookCommand.command; if (commandPath && typeof commandPath === "string") { const validation = validateHookPath(commandPath, rootDir, hooksDir); if (!validation.valid || !validation.relativePath) { console.warn(`Warning: Hook command "${commandPath}" in hooks.json must reference a file within the hooks/ directory. Skipping.`); } } } } } } // Copy all files from the hooks/ directory for (const file of allFiles) { const fileContent = await fs_extra_1.default.readFile(file.absolutePath); const basename = node_path_1.default.basename(file.absolutePath); // Namespace preset files let namespacedPath; if (source === "preset" && presetName) { const namespace = (0, config_1.extractNamespaceFromPresetPath)(presetName); // Use posix paths for consistent cross-platform behavior namespacedPath = node_path_1.default.posix.join(...namespace, file.relativePath.split(node_path_1.default.sep).join(node_path_1.default.posix.sep)); } else { // For local files, use the relative path as-is namespacedPath = file.relativePath.split(node_path_1.default.sep).join(node_path_1.default.posix.sep); } hookFiles.push({ name: namespacedPath, basename, content: fileContent, sourcePath: file.absolutePath, source, presetName, }); } } // Rewrite the config to use namespaced file names const rewrittenConfig = rewriteHooksConfigForNamespace(hooksConfig, hookFiles, rootDir); return { config: rewrittenConfig, files: hookFiles }; } /** * Rewrite hooks config to use the namespaced names from the hook files */ function rewriteHooksConfigForNamespace(hooksConfig, hookFiles, rootDir) { // Create a map from sourcePath to the hookFile const sourcePathToFile = new Map(); for (const hookFile of hookFiles) { sourcePathToFile.set(hookFile.sourcePath, hookFile); } const rewritten = { version: hooksConfig.version, hooks: {}, }; if (hooksConfig.hooks) { for (const hookType of Object.keys(hooksConfig.hooks)) { const hookCommands = hooksConfig.hooks[hookType]; if (hookCommands && Array.isArray(hookCommands)) { rewritten.hooks[hookType] = hookCommands .map((hookCommand) => { const commandPath = hookCommand.command; if (commandPath && typeof commandPath === "string" && (commandPath.startsWith("./") || commandPath.startsWith("../"))) { // Resolve path relative to rootDir (where hooks.json is) const resolvedPath = node_path_1.default.resolve(rootDir, commandPath); const hookFile = sourcePathToFile.get(resolvedPath); if (hookFile) { // Use the namespaced name return { command: hookFile.name }; } // File was invalid or not found, filter it out return null; } return hookCommand; }) .filter((cmd) => cmd !== null); } } } return rewritten; } /** * Merge multiple hooks configurations into one * Later configurations override earlier ones for the same hook type */ function mergeHooksConfigs(configs) { const merged = { version: 1, hooks: {}, }; for (const config of configs) { // Use the latest version if (config.version) { merged.version = config.version; } // Merge hooks - concatenate arrays for each hook type if (config.hooks) { for (const hookType of Object.keys(config.hooks)) { const hookCommands = config.hooks[hookType]; if (hookCommands && Array.isArray(hookCommands)) { if (!merged.hooks[hookType]) { merged.hooks[hookType] = []; } // Concatenate commands (later configs add to the list) merged.hooks[hookType] = [ ...(merged.hooks[hookType] || []), ...hookCommands, ]; } } } } return merged; } /** * Rewrite command paths to point to the managed hooks directory (hooks/aicm/) * At this point, paths are already namespaced filenames from loadHooksFromDirectory */ function rewriteHooksConfigToManagedDir(hooksConfig) { const rewritten = { version: hooksConfig.version, hooks: {}, }; if (hooksConfig.hooks) { for (const hookType of Object.keys(hooksConfig.hooks)) { const hookCommands = hooksConfig.hooks[hookType]; if (hookCommands && Array.isArray(hookCommands)) { rewritten.hooks[hookType] = hookCommands.map((hookCommand) => { const commandPath = hookCommand.command; if (commandPath && typeof commandPath === "string") { return { command: `./hooks/aicm/${commandPath}` }; } return hookCommand; }); } } } return rewritten; } /** * Count the number of hook entries in a hooks configuration */ function countHooks(hooksConfig) { let count = 0; if (hooksConfig.hooks) { for (const hookType of Object.keys(hooksConfig.hooks)) { const hookCommands = hooksConfig.hooks[hookType]; if (hookCommands && Array.isArray(hookCommands)) { count += hookCommands.length; } } } return count; } /** * Dedupe hook files by namespaced path, warn on content conflicts * Presets are namespaced with directories, so same basename from different presets won't collide */ function dedupeHookFiles(hookFiles) { const fileMap = new Map(); for (const hookFile of hookFiles) { const namespacedPath = hookFile.name; if (fileMap.has(namespacedPath)) { const existing = fileMap.get(namespacedPath); const existingHash = node_crypto_1.default .createHash("md5") .update(existing.content) .digest("hex"); const currentHash = node_crypto_1.default .createHash("md5") .update(hookFile.content) .digest("hex"); if (existingHash !== currentHash) { const sourceInfo = hookFile.presetName ? `preset "${hookFile.presetName}"` : hookFile.source; const existingSourceInfo = existing.presetName ? `preset "${existing.presetName}"` : existing.source; console.warn(`Warning: Hook file "${namespacedPath}" has different content from ${existingSourceInfo} and ${sourceInfo}. Using last occurrence.`); } // Last writer wins fileMap.set(namespacedPath, hookFile); } else { fileMap.set(namespacedPath, hookFile); } } return Array.from(fileMap.values()); } /** * Write hooks configuration and files to Cursor target */ function writeHooksToCursor(hooksConfig, hookFiles, cwd) { const cursorRoot = node_path_1.default.join(cwd, ".cursor"); const hooksJsonPath = node_path_1.default.join(cursorRoot, "hooks.json"); const hooksDir = node_path_1.default.join(cursorRoot, "hooks", "aicm"); // Dedupe hook files const dedupedHookFiles = dedupeHookFiles(hookFiles); // Create hooks directory and clean it fs_extra_1.default.emptyDirSync(hooksDir); // Copy hook files to managed directory for (const hookFile of dedupedHookFiles) { const targetPath = node_path_1.default.join(hooksDir, hookFile.name); fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(targetPath)); fs_extra_1.default.writeFileSync(targetPath, hookFile.content); } // Rewrite paths to point to managed directory const finalConfig = rewriteHooksConfigToManagedDir(hooksConfig); // Read existing hooks.json and preserve user hooks let existingConfig = null; if (fs_extra_1.default.existsSync(hooksJsonPath)) { try { existingConfig = fs_extra_1.default.readJsonSync(hooksJsonPath); } catch (_a) { existingConfig = null; } } // Extract user hooks (non-aicm managed) const userHooks = { version: 1, hooks: {} }; if (existingConfig === null || existingConfig === void 0 ? void 0 : existingConfig.hooks) { for (const hookType of Object.keys(existingConfig.hooks)) { const commands = existingConfig.hooks[hookType]; if (commands && Array.isArray(commands)) { const userCommands = commands.filter((cmd) => { var _a; return !((_a = cmd.command) === null || _a === void 0 ? void 0 : _a.includes("hooks/aicm/")); }); if (userCommands.length > 0) { userHooks.hooks[hookType] = userCommands; } } } } // Merge user hooks with aicm hooks const mergedConfig = { version: finalConfig.version, hooks: {}, }; // Add user hooks first if (userHooks.hooks) { for (const hookType of Object.keys(userHooks.hooks)) { const commands = userHooks.hooks[hookType]; if (commands) { mergedConfig.hooks[hookType] = [...commands]; } } } // Then add aicm hooks if (finalConfig.hooks) { for (const hookType of Object.keys(finalConfig.hooks)) { const commands = finalConfig.hooks[hookType]; if (commands) { if (!mergedConfig.hooks[hookType]) { mergedConfig.hooks[hookType] = []; } mergedConfig.hooks[hookType].push(...commands); } } } // Write hooks.json fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(hooksJsonPath)); fs_extra_1.default.writeJsonSync(hooksJsonPath, mergedConfig, { spaces: 2 }); }