aicm
Version:
A TypeScript CLI tool for managing AI IDE rules across different projects and teams
347 lines (346 loc) • 14.7 kB
JavaScript
"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 });
}