aicm 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadHooksFromDirectory = loadHooksFromDirectory;
7
+ exports.mergeHooksConfigs = mergeHooksConfigs;
8
+ exports.rewriteHooksConfigToManagedDir = rewriteHooksConfigToManagedDir;
9
+ exports.countHooks = countHooks;
10
+ exports.dedupeHookFiles = dedupeHookFiles;
11
+ exports.writeHooksToCursor = writeHooksToCursor;
12
+ const node_crypto_1 = __importDefault(require("node:crypto"));
13
+ const fs_extra_1 = __importDefault(require("fs-extra"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const config_1 = require("./config");
16
+ /**
17
+ * Validate that a command path points to a file within the hooks directory
18
+ * Commands should be relative paths starting with ./hooks/
19
+ */
20
+ function validateHookPath(commandPath, rootDir, hooksDir) {
21
+ if (!commandPath.startsWith("./") && !commandPath.startsWith("../")) {
22
+ return { valid: false };
23
+ }
24
+ // Resolve path relative to rootDir (where hooks.json is located)
25
+ const resolvedPath = node_path_1.default.resolve(rootDir, commandPath);
26
+ const relativePath = node_path_1.default.relative(hooksDir, resolvedPath);
27
+ // Check if the file is within hooks directory (not using .. to escape)
28
+ if (relativePath.startsWith("..") || node_path_1.default.isAbsolute(relativePath)) {
29
+ return { valid: false };
30
+ }
31
+ return { valid: true, relativePath };
32
+ }
33
+ /**
34
+ * Load all files from the hooks directory
35
+ */
36
+ async function loadAllFilesFromDirectory(dir, baseDir = dir) {
37
+ const files = [];
38
+ const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
39
+ for (const entry of entries) {
40
+ const fullPath = node_path_1.default.join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ // Recursively load files from subdirectories
43
+ const subFiles = await loadAllFilesFromDirectory(fullPath, baseDir);
44
+ files.push(...subFiles);
45
+ }
46
+ else if (entry.isFile() && entry.name !== "hooks.json") {
47
+ // Skip hooks.json, collect all other files
48
+ const relativePath = node_path_1.default.relative(baseDir, fullPath);
49
+ files.push({ relativePath, absolutePath: fullPath });
50
+ }
51
+ }
52
+ return files;
53
+ }
54
+ /**
55
+ * Load hooks configuration from a root directory
56
+ * The directory should contain hooks.json (sibling to hooks/ directory)
57
+ * All files in the hooks/ subdirectory are copied during installation
58
+ */
59
+ async function loadHooksFromDirectory(rootDir, source, presetName) {
60
+ const hooksFilePath = node_path_1.default.join(rootDir, "hooks.json");
61
+ const hooksDir = node_path_1.default.join(rootDir, "hooks");
62
+ if (!fs_extra_1.default.existsSync(hooksFilePath)) {
63
+ return {
64
+ config: { version: 1, hooks: {} },
65
+ files: [],
66
+ };
67
+ }
68
+ const content = await fs_extra_1.default.readFile(hooksFilePath, "utf8");
69
+ const hooksConfig = JSON.parse(content);
70
+ // Load all files from hooks/ subdirectory
71
+ const hookFiles = [];
72
+ if (fs_extra_1.default.existsSync(hooksDir)) {
73
+ const allFiles = await loadAllFilesFromDirectory(hooksDir);
74
+ // Create a map of all files for validation
75
+ const filePathMap = new Map();
76
+ for (const file of allFiles) {
77
+ filePathMap.set(file.relativePath, file.absolutePath);
78
+ }
79
+ // Validate that all referenced commands point to files within hooks directory
80
+ if (hooksConfig.hooks) {
81
+ for (const hookType of Object.keys(hooksConfig.hooks)) {
82
+ const hookCommands = hooksConfig.hooks[hookType];
83
+ if (hookCommands && Array.isArray(hookCommands)) {
84
+ for (const hookCommand of hookCommands) {
85
+ const commandPath = hookCommand.command;
86
+ if (commandPath && typeof commandPath === "string") {
87
+ const validation = validateHookPath(commandPath, rootDir, hooksDir);
88
+ if (!validation.valid || !validation.relativePath) {
89
+ console.warn(`Warning: Hook command "${commandPath}" in hooks.json must reference a file within the hooks/ directory. Skipping.`);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ // Copy all files from the hooks/ directory
97
+ for (const file of allFiles) {
98
+ const fileContent = await fs_extra_1.default.readFile(file.absolutePath);
99
+ const basename = node_path_1.default.basename(file.absolutePath);
100
+ // Namespace preset files
101
+ let namespacedPath;
102
+ if (source === "preset" && presetName) {
103
+ const namespace = (0, config_1.extractNamespaceFromPresetPath)(presetName);
104
+ // Use posix paths for consistent cross-platform behavior
105
+ namespacedPath = node_path_1.default.posix.join(...namespace, file.relativePath.split(node_path_1.default.sep).join(node_path_1.default.posix.sep));
106
+ }
107
+ else {
108
+ // For local files, use the relative path as-is
109
+ namespacedPath = file.relativePath.split(node_path_1.default.sep).join(node_path_1.default.posix.sep);
110
+ }
111
+ hookFiles.push({
112
+ name: namespacedPath,
113
+ basename,
114
+ content: fileContent,
115
+ sourcePath: file.absolutePath,
116
+ source,
117
+ presetName,
118
+ });
119
+ }
120
+ }
121
+ // Rewrite the config to use namespaced file names
122
+ const rewrittenConfig = rewriteHooksConfigForNamespace(hooksConfig, hookFiles, rootDir);
123
+ return { config: rewrittenConfig, files: hookFiles };
124
+ }
125
+ /**
126
+ * Rewrite hooks config to use the namespaced names from the hook files
127
+ */
128
+ function rewriteHooksConfigForNamespace(hooksConfig, hookFiles, rootDir) {
129
+ // Create a map from sourcePath to the hookFile
130
+ const sourcePathToFile = new Map();
131
+ for (const hookFile of hookFiles) {
132
+ sourcePathToFile.set(hookFile.sourcePath, hookFile);
133
+ }
134
+ const rewritten = {
135
+ version: hooksConfig.version,
136
+ hooks: {},
137
+ };
138
+ if (hooksConfig.hooks) {
139
+ for (const hookType of Object.keys(hooksConfig.hooks)) {
140
+ const hookCommands = hooksConfig.hooks[hookType];
141
+ if (hookCommands && Array.isArray(hookCommands)) {
142
+ rewritten.hooks[hookType] = hookCommands
143
+ .map((hookCommand) => {
144
+ const commandPath = hookCommand.command;
145
+ if (commandPath &&
146
+ typeof commandPath === "string" &&
147
+ (commandPath.startsWith("./") || commandPath.startsWith("../"))) {
148
+ // Resolve path relative to rootDir (where hooks.json is)
149
+ const resolvedPath = node_path_1.default.resolve(rootDir, commandPath);
150
+ const hookFile = sourcePathToFile.get(resolvedPath);
151
+ if (hookFile) {
152
+ // Use the namespaced name
153
+ return { command: hookFile.name };
154
+ }
155
+ // File was invalid or not found, filter it out
156
+ return null;
157
+ }
158
+ return hookCommand;
159
+ })
160
+ .filter((cmd) => cmd !== null);
161
+ }
162
+ }
163
+ }
164
+ return rewritten;
165
+ }
166
+ /**
167
+ * Merge multiple hooks configurations into one
168
+ * Later configurations override earlier ones for the same hook type
169
+ */
170
+ function mergeHooksConfigs(configs) {
171
+ const merged = {
172
+ version: 1,
173
+ hooks: {},
174
+ };
175
+ for (const config of configs) {
176
+ // Use the latest version
177
+ if (config.version) {
178
+ merged.version = config.version;
179
+ }
180
+ // Merge hooks - concatenate arrays for each hook type
181
+ if (config.hooks) {
182
+ for (const hookType of Object.keys(config.hooks)) {
183
+ const hookCommands = config.hooks[hookType];
184
+ if (hookCommands && Array.isArray(hookCommands)) {
185
+ if (!merged.hooks[hookType]) {
186
+ merged.hooks[hookType] = [];
187
+ }
188
+ // Concatenate commands (later configs add to the list)
189
+ merged.hooks[hookType] = [
190
+ ...(merged.hooks[hookType] || []),
191
+ ...hookCommands,
192
+ ];
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return merged;
198
+ }
199
+ /**
200
+ * Rewrite command paths to point to the managed hooks directory (hooks/aicm/)
201
+ * At this point, paths are already namespaced filenames from loadHooksFromDirectory
202
+ */
203
+ function rewriteHooksConfigToManagedDir(hooksConfig) {
204
+ const rewritten = {
205
+ version: hooksConfig.version,
206
+ hooks: {},
207
+ };
208
+ if (hooksConfig.hooks) {
209
+ for (const hookType of Object.keys(hooksConfig.hooks)) {
210
+ const hookCommands = hooksConfig.hooks[hookType];
211
+ if (hookCommands && Array.isArray(hookCommands)) {
212
+ rewritten.hooks[hookType] = hookCommands.map((hookCommand) => {
213
+ const commandPath = hookCommand.command;
214
+ if (commandPath && typeof commandPath === "string") {
215
+ return { command: `./hooks/aicm/${commandPath}` };
216
+ }
217
+ return hookCommand;
218
+ });
219
+ }
220
+ }
221
+ }
222
+ return rewritten;
223
+ }
224
+ /**
225
+ * Count the number of hook entries in a hooks configuration
226
+ */
227
+ function countHooks(hooksConfig) {
228
+ let count = 0;
229
+ if (hooksConfig.hooks) {
230
+ for (const hookType of Object.keys(hooksConfig.hooks)) {
231
+ const hookCommands = hooksConfig.hooks[hookType];
232
+ if (hookCommands && Array.isArray(hookCommands)) {
233
+ count += hookCommands.length;
234
+ }
235
+ }
236
+ }
237
+ return count;
238
+ }
239
+ /**
240
+ * Dedupe hook files by namespaced path, warn on content conflicts
241
+ * Presets are namespaced with directories, so same basename from different presets won't collide
242
+ */
243
+ function dedupeHookFiles(hookFiles) {
244
+ const fileMap = new Map();
245
+ for (const hookFile of hookFiles) {
246
+ const namespacedPath = hookFile.name;
247
+ if (fileMap.has(namespacedPath)) {
248
+ const existing = fileMap.get(namespacedPath);
249
+ const existingHash = node_crypto_1.default
250
+ .createHash("md5")
251
+ .update(existing.content)
252
+ .digest("hex");
253
+ const currentHash = node_crypto_1.default
254
+ .createHash("md5")
255
+ .update(hookFile.content)
256
+ .digest("hex");
257
+ if (existingHash !== currentHash) {
258
+ const sourceInfo = hookFile.presetName
259
+ ? `preset "${hookFile.presetName}"`
260
+ : hookFile.source;
261
+ const existingSourceInfo = existing.presetName
262
+ ? `preset "${existing.presetName}"`
263
+ : existing.source;
264
+ console.warn(`Warning: Hook file "${namespacedPath}" has different content from ${existingSourceInfo} and ${sourceInfo}. Using last occurrence.`);
265
+ }
266
+ // Last writer wins
267
+ fileMap.set(namespacedPath, hookFile);
268
+ }
269
+ else {
270
+ fileMap.set(namespacedPath, hookFile);
271
+ }
272
+ }
273
+ return Array.from(fileMap.values());
274
+ }
275
+ /**
276
+ * Write hooks configuration and files to Cursor target
277
+ */
278
+ function writeHooksToCursor(hooksConfig, hookFiles, cwd) {
279
+ const cursorRoot = node_path_1.default.join(cwd, ".cursor");
280
+ const hooksJsonPath = node_path_1.default.join(cursorRoot, "hooks.json");
281
+ const hooksDir = node_path_1.default.join(cursorRoot, "hooks", "aicm");
282
+ // Dedupe hook files
283
+ const dedupedHookFiles = dedupeHookFiles(hookFiles);
284
+ // Create hooks directory and clean it
285
+ fs_extra_1.default.emptyDirSync(hooksDir);
286
+ // Copy hook files to managed directory
287
+ for (const hookFile of dedupedHookFiles) {
288
+ const targetPath = node_path_1.default.join(hooksDir, hookFile.name);
289
+ fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(targetPath));
290
+ fs_extra_1.default.writeFileSync(targetPath, hookFile.content);
291
+ }
292
+ // Rewrite paths to point to managed directory
293
+ const finalConfig = rewriteHooksConfigToManagedDir(hooksConfig);
294
+ // Read existing hooks.json and preserve user hooks
295
+ let existingConfig = null;
296
+ if (fs_extra_1.default.existsSync(hooksJsonPath)) {
297
+ try {
298
+ existingConfig = fs_extra_1.default.readJsonSync(hooksJsonPath);
299
+ }
300
+ catch (_a) {
301
+ existingConfig = null;
302
+ }
303
+ }
304
+ // Extract user hooks (non-aicm managed)
305
+ const userHooks = { version: 1, hooks: {} };
306
+ if (existingConfig === null || existingConfig === void 0 ? void 0 : existingConfig.hooks) {
307
+ for (const hookType of Object.keys(existingConfig.hooks)) {
308
+ const commands = existingConfig.hooks[hookType];
309
+ if (commands && Array.isArray(commands)) {
310
+ const userCommands = commands.filter((cmd) => { var _a; return !((_a = cmd.command) === null || _a === void 0 ? void 0 : _a.includes("hooks/aicm/")); });
311
+ if (userCommands.length > 0) {
312
+ userHooks.hooks[hookType] = userCommands;
313
+ }
314
+ }
315
+ }
316
+ }
317
+ // Merge user hooks with aicm hooks
318
+ const mergedConfig = {
319
+ version: finalConfig.version,
320
+ hooks: {},
321
+ };
322
+ // Add user hooks first
323
+ if (userHooks.hooks) {
324
+ for (const hookType of Object.keys(userHooks.hooks)) {
325
+ const commands = userHooks.hooks[hookType];
326
+ if (commands) {
327
+ mergedConfig.hooks[hookType] = [...commands];
328
+ }
329
+ }
330
+ }
331
+ // Then add aicm hooks
332
+ if (finalConfig.hooks) {
333
+ for (const hookType of Object.keys(finalConfig.hooks)) {
334
+ const commands = finalConfig.hooks[hookType];
335
+ if (commands) {
336
+ if (!mergedConfig.hooks[hookType]) {
337
+ mergedConfig.hooks[hookType] = [];
338
+ }
339
+ mergedConfig.hooks[hookType].push(...commands);
340
+ }
341
+ }
342
+ }
343
+ // Write hooks.json
344
+ fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(hooksJsonPath));
345
+ fs_extra_1.default.writeJsonSync(hooksJsonPath, mergedConfig, { spaces: 2 });
346
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicm",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "A TypeScript CLI tool for managing AI IDE rules across different projects and teams",
5
5
  "main": "dist/api.js",
6
6
  "types": "dist/api.d.ts",