UNPKG

aicm

Version:

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

737 lines (736 loc) 30.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.writeAssetsToTargets = writeAssetsToTargets; exports.writeCommandsToTargets = writeCommandsToTargets; exports.writeSkillsToTargets = writeSkillsToTargets; exports.warnPresetSkillCollisions = warnPresetSkillCollisions; exports.dedupeSkillsForInstall = dedupeSkillsForInstall; exports.writeAgentsToTargets = writeAgentsToTargets; exports.warnPresetAgentCollisions = warnPresetAgentCollisions; exports.dedupeAgentsForInstall = dedupeAgentsForInstall; exports.warnPresetCommandCollisions = warnPresetCommandCollisions; exports.dedupeCommandsForInstall = dedupeCommandsForInstall; exports.writeMcpServersToFile = writeMcpServersToFile; exports.installPackage = installPackage; exports.install = install; exports.installCommand = installCommand; const chalk_1 = __importDefault(require("chalk")); const fs_extra_1 = __importDefault(require("fs-extra")); const node_path_1 = __importDefault(require("node:path")); const config_1 = require("../utils/config"); const hooks_1 = require("../utils/hooks"); const working_directory_1 = require("../utils/working-directory"); const is_ci_1 = require("../utils/is-ci"); const rules_file_writer_1 = require("../utils/rules-file-writer"); const install_workspaces_1 = require("./install-workspaces"); /** * Rewrite asset references from source paths to installation paths * Only rewrites the ../assets/ pattern - everything else is preserved * * @param content - The file content to rewrite * @param presetName - The preset name if this file is from a preset * @param fileInstallDepth - The depth of the file's installation directory relative to .cursor/ * For example: .cursor/commands/aicm/file.md has depth 2 (commands, aicm) * .cursor/rules/aicm/preset/file.mdc has depth 3 (rules, aicm, preset) */ function rewriteAssetReferences(content, presetName, fileInstallDepth = 2) { // Calculate the relative path from the file to .cursor/assets/aicm/ // We need to go up fileInstallDepth levels to reach .cursor/, then down to assets/aicm/ const upLevels = "../".repeat(fileInstallDepth); // If this is from a preset, include the preset namespace in the asset path let assetBasePath = "assets/aicm/"; if (presetName) { const namespace = (0, config_1.extractNamespaceFromPresetPath)(presetName); assetBasePath = node_path_1.default.posix.join("assets", "aicm", ...namespace) + "/"; } const targetPath = upLevels + assetBasePath; // Replace ../assets/ with the calculated target path // Handles both forward slashes and backslashes for cross-platform compatibility return content.replace(/\.\.[\\/]assets[\\/]/g, targetPath); } function getTargetPaths() { const projectDir = process.cwd(); return { cursor: node_path_1.default.join(projectDir, ".cursor", "rules", "aicm"), assetsAicm: node_path_1.default.join(projectDir, ".cursor", "assets", "aicm"), aicm: node_path_1.default.join(projectDir, ".aicm"), }; } function writeCursorRules(rules, cursorRulesDir) { fs_extra_1.default.emptyDirSync(cursorRulesDir); for (const rule of rules) { let rulePath; const ruleNameParts = rule.name.split(node_path_1.default.sep).filter(Boolean); if (rule.presetName) { // For rules from presets, create a namespaced directory structure const namespace = (0, config_1.extractNamespaceFromPresetPath)(rule.presetName); // Path will be: cursorRulesDir/namespace/rule-name.mdc rulePath = node_path_1.default.join(cursorRulesDir, ...namespace, ...ruleNameParts); } else { // For local rules, maintain the original flat structure rulePath = node_path_1.default.join(cursorRulesDir, ...ruleNameParts); } const ruleFile = rulePath + ".mdc"; fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(ruleFile)); // Calculate the depth for asset path rewriting // cursorRulesDir is .cursor/rules/aicm (depth 2 from .cursor) // Add namespace depth if present let fileInstallDepth = 2; // rules, aicm if (rule.presetName) { const namespace = (0, config_1.extractNamespaceFromPresetPath)(rule.presetName); fileInstallDepth += namespace.length; } // Add any subdirectories in the rule name fileInstallDepth += ruleNameParts.length - 1; // -1 because the last part is the filename // Rewrite asset references before writing const content = rewriteAssetReferences(rule.content, rule.presetName, fileInstallDepth); fs_extra_1.default.writeFileSync(ruleFile, content); } } function writeCursorCommands(commands, cursorCommandsDir) { fs_extra_1.default.removeSync(cursorCommandsDir); for (const command of commands) { const commandNameParts = command.name .replace(/\\/g, "/") .split("/") .filter(Boolean); const commandPath = node_path_1.default.join(cursorCommandsDir, ...commandNameParts); const commandFile = commandPath + ".md"; fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(commandFile)); // Calculate the depth for asset path rewriting // cursorCommandsDir is .cursor/commands/aicm (depth 2 from .cursor) // Commands are NOT namespaced by preset, but we still need to account for subdirectories let fileInstallDepth = 2; // commands, aicm // Add any subdirectories in the command name fileInstallDepth += commandNameParts.length - 1; // -1 because the last part is the filename // Rewrite asset references before writing const content = rewriteAssetReferences(command.content, command.presetName, fileInstallDepth); fs_extra_1.default.writeFileSync(commandFile, content); } } /** * Write rules to a shared directory and update the given rules file */ function writeRulesForFile(rules, assets, ruleDir, rulesFile) { fs_extra_1.default.emptyDirSync(ruleDir); const ruleFiles = rules.map((rule) => { let rulePath; const ruleNameParts = rule.name.split(node_path_1.default.sep).filter(Boolean); if (rule.presetName) { // For rules from presets, create a namespaced directory structure const namespace = (0, config_1.extractNamespaceFromPresetPath)(rule.presetName); // Path will be: ruleDir/namespace/rule-name.md rulePath = node_path_1.default.join(ruleDir, ...namespace, ...ruleNameParts); } else { // For local rules, maintain the original flat structure rulePath = node_path_1.default.join(ruleDir, ...ruleNameParts); } // For windsurf/codex/claude, assets are installed at the same namespace level as rules // Example: .aicm/my-preset/rule.md and .aicm/my-preset/asset.json // So we need to remove the 'assets/' part from the path // ../assets/file.json -> ../file.json // ../../assets/file.json -> ../../file.json const content = rule.content.replace(/(\.\.[/\\])assets[/\\]/g, "$1"); const physicalRulePath = rulePath + ".md"; fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(physicalRulePath)); fs_extra_1.default.writeFileSync(physicalRulePath, content); const relativeRuleDir = node_path_1.default.basename(ruleDir); // For the rules file, maintain the same structure let windsurfPath; if (rule.presetName) { const namespace = (0, config_1.extractNamespaceFromPresetPath)(rule.presetName); windsurfPath = node_path_1.default.join(relativeRuleDir, ...namespace, ...ruleNameParts) + ".md"; } else { windsurfPath = node_path_1.default.join(relativeRuleDir, ...ruleNameParts) + ".md"; } // Normalize to POSIX style for cross-platform compatibility const windsurfPathPosix = windsurfPath.replace(/\\/g, "/"); return { name: rule.name, path: windsurfPathPosix, metadata: (0, rules_file_writer_1.parseRuleFrontmatter)(content), }; }); const rulesContent = (0, rules_file_writer_1.generateRulesFileContent)(ruleFiles); (0, rules_file_writer_1.writeRulesFile)(rulesContent, node_path_1.default.join(process.cwd(), rulesFile)); } function writeAssetsToTargets(assets, targets) { const targetPaths = getTargetPaths(); for (const target of targets) { let targetDir; switch (target) { case "cursor": targetDir = targetPaths.assetsAicm; break; case "windsurf": case "codex": case "claude": targetDir = targetPaths.aicm; break; default: continue; } for (const asset of assets) { let assetPath; if (asset.presetName) { const namespace = (0, config_1.extractNamespaceFromPresetPath)(asset.presetName); assetPath = node_path_1.default.join(targetDir, ...namespace, asset.name); } else { assetPath = node_path_1.default.join(targetDir, asset.name); } fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(assetPath)); fs_extra_1.default.writeFileSync(assetPath, asset.content); } } } /** * Write all collected rules to their respective IDE targets */ function writeRulesToTargets(rules, assets, targets) { const targetPaths = getTargetPaths(); for (const target of targets) { switch (target) { case "cursor": if (rules.length > 0) { writeCursorRules(rules, targetPaths.cursor); } break; case "windsurf": if (rules.length > 0) { writeRulesForFile(rules, assets, targetPaths.aicm, ".windsurfrules"); } break; case "codex": if (rules.length > 0) { writeRulesForFile(rules, assets, targetPaths.aicm, "AGENTS.md"); } break; case "claude": if (rules.length > 0) { writeRulesForFile(rules, assets, targetPaths.aicm, "CLAUDE.md"); } break; } } // Write assets after rules so they don't get wiped by emptyDirSync writeAssetsToTargets(assets, targets); } function writeCommandsToTargets(commands, targets) { const projectDir = process.cwd(); const cursorRoot = node_path_1.default.join(projectDir, ".cursor"); for (const target of targets) { if (target === "cursor") { const commandsDir = node_path_1.default.join(cursorRoot, "commands", "aicm"); writeCursorCommands(commands, commandsDir); } // Other targets do not support commands yet } } /** * Get the skills installation path for a target * Returns null for targets that don't support skills */ function getSkillsTargetPath(target) { const projectDir = process.cwd(); switch (target) { case "cursor": return node_path_1.default.join(projectDir, ".cursor", "skills"); case "claude": return node_path_1.default.join(projectDir, ".claude", "skills"); case "codex": return node_path_1.default.join(projectDir, ".codex", "skills"); case "windsurf": // Windsurf does not support skills return null; default: return null; } } /** * Write a single skill to the target directory * Copies the entire skill directory and writes .aicm.json metadata */ function writeSkillToTarget(skill, targetSkillsDir) { const skillTargetPath = node_path_1.default.join(targetSkillsDir, skill.name); // Remove existing skill directory if it exists (to ensure clean install) if (fs_extra_1.default.existsSync(skillTargetPath)) { fs_extra_1.default.removeSync(skillTargetPath); } // Copy the entire skill directory fs_extra_1.default.copySync(skill.sourcePath, skillTargetPath); // Write .aicm.json metadata file // The presence of this file indicates the skill is managed by aicm const metadata = { source: skill.source, }; if (skill.presetName) { metadata.presetName = skill.presetName; } const metadataPath = node_path_1.default.join(skillTargetPath, ".aicm.json"); fs_extra_1.default.writeJsonSync(metadataPath, metadata, { spaces: 2 }); } /** * Write skills to all supported target directories */ function writeSkillsToTargets(skills, targets) { if (skills.length === 0) return; for (const target of targets) { const targetSkillsDir = getSkillsTargetPath(target); if (!targetSkillsDir) { // Target doesn't support skills continue; } // Ensure the skills directory exists fs_extra_1.default.ensureDirSync(targetSkillsDir); for (const skill of skills) { writeSkillToTarget(skill, targetSkillsDir); } } } /** * Warn about skill name collisions from different presets */ function warnPresetSkillCollisions(skills) { const collisions = new Map(); for (const skill of skills) { if (!skill.presetName) continue; const entry = collisions.get(skill.name); if (entry) { entry.presets.add(skill.presetName); entry.lastPreset = skill.presetName; } else { collisions.set(skill.name, { presets: new Set([skill.presetName]), lastPreset: skill.presetName, }); } } for (const [skillName, { presets, lastPreset }] of collisions) { if (presets.size > 1) { const presetList = Array.from(presets).sort().join(", "); console.warn(chalk_1.default.yellow(`Warning: multiple presets provide the "${skillName}" skill (${presetList}). Using definition from ${lastPreset}.`)); } } } /** * Dedupe skills by name (last one wins) */ function dedupeSkillsForInstall(skills) { const unique = new Map(); for (const skill of skills) { unique.set(skill.name, skill); } return Array.from(unique.values()); } /** * Get the agents installation path for a target * Returns null for targets that don't support agents */ function getAgentsTargetPath(target) { const projectDir = process.cwd(); switch (target) { case "cursor": return node_path_1.default.join(projectDir, ".cursor", "agents"); case "claude": return node_path_1.default.join(projectDir, ".claude", "agents"); case "codex": case "windsurf": // Codex and Windsurf do not support agents return null; default: return null; } } /** * Write agents to all supported target directories * Similar to skills, agents are written directly to the agents directory * with a .aicm.json metadata file tracking which agents are managed */ function writeAgentsToTargets(agents, targets) { if (agents.length === 0) return; for (const target of targets) { const targetAgentsDir = getAgentsTargetPath(target); if (!targetAgentsDir) { // Target doesn't support agents continue; } // Ensure the agents directory exists fs_extra_1.default.ensureDirSync(targetAgentsDir); // Read existing metadata to clean up old managed agents const metadataPath = node_path_1.default.join(targetAgentsDir, ".aicm.json"); if (fs_extra_1.default.existsSync(metadataPath)) { try { const existingMetadata = fs_extra_1.default.readJsonSync(metadataPath); // Remove previously managed agents for (const agentName of existingMetadata.managedAgents || []) { // Skip invalid names containing path separators if (agentName.includes("/") || agentName.includes("\\")) { console.warn(chalk_1.default.yellow(`Warning: Skipping invalid agent name "${agentName}" (contains path separator)`)); continue; } const fullPath = node_path_1.default.join(targetAgentsDir, agentName + ".md"); if (fs_extra_1.default.existsSync(fullPath)) { fs_extra_1.default.removeSync(fullPath); } } } catch (_a) { // Ignore errors reading metadata } } const managedAgents = []; for (const agent of agents) { // Use base name only const agentName = node_path_1.default.basename(agent.name, node_path_1.default.extname(agent.name)); const agentFile = node_path_1.default.join(targetAgentsDir, agentName + ".md"); fs_extra_1.default.writeFileSync(agentFile, agent.content); managedAgents.push(agentName); } // Write metadata file to track managed agents const metadata = { managedAgents, }; fs_extra_1.default.writeJsonSync(metadataPath, metadata, { spaces: 2 }); } } /** * Warn about agent name collisions from different presets */ function warnPresetAgentCollisions(agents) { const collisions = new Map(); for (const agent of agents) { if (!agent.presetName) continue; const entry = collisions.get(agent.name); if (entry) { entry.presets.add(agent.presetName); entry.lastPreset = agent.presetName; } else { collisions.set(agent.name, { presets: new Set([agent.presetName]), lastPreset: agent.presetName, }); } } for (const [agentName, { presets, lastPreset }] of collisions) { if (presets.size > 1) { const presetList = Array.from(presets).sort().join(", "); console.warn(chalk_1.default.yellow(`Warning: multiple presets provide the "${agentName}" agent (${presetList}). Using definition from ${lastPreset}.`)); } } } /** * Dedupe agents by name (last one wins) */ function dedupeAgentsForInstall(agents) { const unique = new Map(); for (const agent of agents) { unique.set(agent.name, agent); } return Array.from(unique.values()); } function warnPresetCommandCollisions(commands) { const collisions = new Map(); for (const command of commands) { if (!command.presetName) continue; const entry = collisions.get(command.name); if (entry) { entry.presets.add(command.presetName); entry.lastPreset = command.presetName; } else { collisions.set(command.name, { presets: new Set([command.presetName]), lastPreset: command.presetName, }); } } for (const [commandName, { presets, lastPreset }] of collisions) { if (presets.size > 1) { const presetList = Array.from(presets).sort().join(", "); console.warn(chalk_1.default.yellow(`Warning: multiple presets provide the "${commandName}" command (${presetList}). Using definition from ${lastPreset}.`)); } } } function dedupeCommandsForInstall(commands) { const unique = new Map(); for (const command of commands) { unique.set(command.name, command); } return Array.from(unique.values()); } /** * Write MCP servers configuration to IDE targets */ function writeMcpServersToTargets(mcpServers, targets, cwd) { if (!mcpServers || Object.keys(mcpServers).length === 0) return; for (const target of targets) { if (target === "cursor") { const mcpPath = node_path_1.default.join(cwd, ".cursor", "mcp.json"); writeMcpServersToFile(mcpServers, mcpPath); } // Windsurf and Codex do not support project mcpServers, so skip } } /** * Write hooks to IDE targets */ function writeHooksToTargets(hooksConfig, hookFiles, targets, cwd) { const hasHooks = hooksConfig.hooks && Object.keys(hooksConfig.hooks).length > 0; if (!hasHooks && hookFiles.length === 0) { return; } for (const target of targets) { if (target === "cursor") { (0, hooks_1.writeHooksToCursor)(hooksConfig, hookFiles, cwd); } // Other targets do not support hooks yet } } /** * Write MCP servers configuration to a specific file */ function writeMcpServersToFile(mcpServers, mcpPath) { var _a; fs_extra_1.default.ensureDirSync(node_path_1.default.dirname(mcpPath)); const existingConfig = fs_extra_1.default.existsSync(mcpPath) ? fs_extra_1.default.readJsonSync(mcpPath) : {}; const existingMcpServers = (_a = existingConfig === null || existingConfig === void 0 ? void 0 : existingConfig.mcpServers) !== null && _a !== void 0 ? _a : {}; // Filter out any existing aicm-managed servers (with aicm: true) // This removes stale aicm servers that are no longer in the configuration const userMcpServers = {}; for (const [key, value] of Object.entries(existingMcpServers)) { if (typeof value === "object" && value !== null && value.aicm !== true) { userMcpServers[key] = value; } } // Mark new aicm servers as managed and filter out canceled servers const aicmMcpServers = {}; for (const [key, value] of Object.entries(mcpServers)) { if (value !== false) { aicmMcpServers[key] = { ...value, aicm: true, }; } } // Merge user servers with aicm servers (aicm servers override user servers with same key) const mergedMcpServers = { ...userMcpServers, ...aicmMcpServers, }; const mergedConfig = { ...existingConfig, mcpServers: mergedMcpServers, }; fs_extra_1.default.writeJsonSync(mcpPath, mergedConfig, { spaces: 2 }); } /** * Install rules for a single package (used within workspaces and standalone installs) */ async function installPackage(options = {}) { const cwd = options.cwd || process.cwd(); return (0, working_directory_1.withWorkingDirectory)(cwd, async () => { let resolvedConfig; if (options.config) { resolvedConfig = options.config; } else { resolvedConfig = await (0, config_1.loadConfig)(cwd); } if (!resolvedConfig) { return { success: false, error: new Error("Configuration file not found"), installedRuleCount: 0, installedCommandCount: 0, installedAssetCount: 0, installedHookCount: 0, installedSkillCount: 0, installedAgentCount: 0, packagesCount: 0, }; } const { config, rules, commands, assets, skills, agents, mcpServers, hooks, hookFiles, } = resolvedConfig; if (config.skipInstall === true) { return { success: true, installedRuleCount: 0, installedCommandCount: 0, installedAssetCount: 0, installedHookCount: 0, installedSkillCount: 0, installedAgentCount: 0, packagesCount: 0, }; } warnPresetCommandCollisions(commands); const commandsToInstall = dedupeCommandsForInstall(commands); warnPresetSkillCollisions(skills); const skillsToInstall = dedupeSkillsForInstall(skills); warnPresetAgentCollisions(agents); const agentsToInstall = dedupeAgentsForInstall(agents); try { if (!options.dryRun) { writeRulesToTargets(rules, assets, config.targets); writeCommandsToTargets(commandsToInstall, config.targets); writeSkillsToTargets(skillsToInstall, config.targets); writeAgentsToTargets(agentsToInstall, config.targets); if (mcpServers && Object.keys(mcpServers).length > 0) { writeMcpServersToTargets(mcpServers, config.targets, cwd); } if (hooks && ((0, hooks_1.countHooks)(hooks) > 0 || hookFiles.length > 0)) { writeHooksToTargets(hooks, hookFiles, config.targets, cwd); } } const uniqueRuleCount = new Set(rules.map((rule) => rule.name)).size; const uniqueCommandCount = new Set(commandsToInstall.map((command) => command.name)).size; const uniqueHookCount = (0, hooks_1.countHooks)(hooks); const uniqueSkillCount = skillsToInstall.length; const uniqueAgentCount = agentsToInstall.length; return { success: true, installedRuleCount: uniqueRuleCount, installedCommandCount: uniqueCommandCount, installedAssetCount: assets.length, installedHookCount: uniqueHookCount, installedSkillCount: uniqueSkillCount, installedAgentCount: uniqueAgentCount, packagesCount: 1, }; } catch (error) { return { success: false, error: error instanceof Error ? error : new Error(String(error)), installedRuleCount: 0, installedCommandCount: 0, installedAssetCount: 0, installedHookCount: 0, installedSkillCount: 0, installedAgentCount: 0, packagesCount: 0, }; } }); } /** * Core implementation of the rule installation logic */ async function install(options = {}) { const cwd = options.cwd || process.cwd(); const installOnCI = options.installOnCI === true; // Default to false if not specified const inCI = (0, is_ci_1.isCIEnvironment)(); if (inCI && !installOnCI) { console.log(chalk_1.default.yellow("Detected CI environment, skipping install.")); return { success: true, installedRuleCount: 0, installedCommandCount: 0, installedAssetCount: 0, installedHookCount: 0, installedSkillCount: 0, installedAgentCount: 0, packagesCount: 0, }; } return (0, working_directory_1.withWorkingDirectory)(cwd, async () => { let resolvedConfig; if (options.config) { resolvedConfig = options.config; } else { resolvedConfig = await (0, config_1.loadConfig)(cwd); } const shouldUseWorkspaces = (resolvedConfig === null || resolvedConfig === void 0 ? void 0 : resolvedConfig.config.workspaces) || (!resolvedConfig && (0, config_1.detectWorkspacesFromPackageJson)(cwd)); if (shouldUseWorkspaces) { return await (0, install_workspaces_1.installWorkspaces)(cwd, installOnCI, options.verbose, options.dryRun); } return installPackage(options); }); } /** * CLI command wrapper for install */ async function installCommand(installOnCI, verbose, dryRun) { var _a; const result = await install({ installOnCI, verbose, dryRun }); if (!result.success) { throw (_a = result.error) !== null && _a !== void 0 ? _a : new Error("Installation failed with unknown error"); } else { const ruleCount = result.installedRuleCount; const commandCount = result.installedCommandCount; const hookCount = result.installedHookCount; const skillCount = result.installedSkillCount; const agentCount = result.installedAgentCount; const ruleMessage = ruleCount > 0 ? `${ruleCount} rule${ruleCount === 1 ? "" : "s"}` : null; const commandMessage = commandCount > 0 ? `${commandCount} command${commandCount === 1 ? "" : "s"}` : null; const hookMessage = hookCount > 0 ? `${hookCount} hook${hookCount === 1 ? "" : "s"}` : null; const skillMessage = skillCount > 0 ? `${skillCount} skill${skillCount === 1 ? "" : "s"}` : null; const agentMessage = agentCount > 0 ? `${agentCount} agent${agentCount === 1 ? "" : "s"}` : null; const countsParts = []; if (ruleMessage) { countsParts.push(ruleMessage); } if (commandMessage) { countsParts.push(commandMessage); } if (hookMessage) { countsParts.push(hookMessage); } if (skillMessage) { countsParts.push(skillMessage); } if (agentMessage) { countsParts.push(agentMessage); } const countsMessage = countsParts.length > 0 ? countsParts.join(", ").replace(/, ([^,]*)$/, " and $1") : "0 rules"; if (dryRun) { if (result.packagesCount > 1) { console.log(`Dry run: validated ${countsMessage} across ${result.packagesCount} packages`); } else { console.log(`Dry run: validated ${countsMessage}`); } } else if (ruleCount === 0 && commandCount === 0 && hookCount === 0 && skillCount === 0 && agentCount === 0) { console.log("No rules, commands, hooks, skills, or agents installed"); } else if (result.packagesCount > 1) { console.log(`Successfully installed ${countsMessage} across ${result.packagesCount} packages`); } else { console.log(`Successfully installed ${countsMessage}`); } } }