UNPKG

aicm

Version:

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

583 lines (582 loc) 24.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SUPPORTED_TARGETS = exports.ALLOWED_CONFIG_KEYS = void 0; exports.detectWorkspacesFromPackageJson = detectWorkspacesFromPackageJson; exports.resolveWorkspaces = resolveWorkspaces; exports.applyDefaults = applyDefaults; exports.validateConfig = validateConfig; exports.loadRulesFromDirectory = loadRulesFromDirectory; exports.loadCommandsFromDirectory = loadCommandsFromDirectory; exports.loadAssetsFromDirectory = loadAssetsFromDirectory; exports.loadSkillsFromDirectory = loadSkillsFromDirectory; exports.loadAgentsFromDirectory = loadAgentsFromDirectory; exports.extractNamespaceFromPresetPath = extractNamespaceFromPresetPath; exports.resolvePresetPath = resolvePresetPath; exports.loadPreset = loadPreset; exports.loadAllRules = loadAllRules; exports.loadConfigFile = loadConfigFile; exports.checkWorkspacesEnabled = checkWorkspacesEnabled; exports.loadConfig = loadConfig; exports.saveConfig = saveConfig; const fs_extra_1 = __importDefault(require("fs-extra")); const node_path_1 = __importDefault(require("node:path")); const cosmiconfig_1 = require("cosmiconfig"); const fast_glob_1 = __importDefault(require("fast-glob")); const hooks_1 = require("./hooks"); exports.ALLOWED_CONFIG_KEYS = [ "rootDir", "targets", "presets", "mcpServers", "workspaces", "skipInstall", ]; exports.SUPPORTED_TARGETS = [ "cursor", "windsurf", "codex", "claude", ]; function detectWorkspacesFromPackageJson(cwd) { try { const packageJsonPath = node_path_1.default.join(cwd, "package.json"); if (!fs_extra_1.default.existsSync(packageJsonPath)) { return false; } const packageJson = JSON.parse(fs_extra_1.default.readFileSync(packageJsonPath, "utf8")); return Boolean(packageJson.workspaces); } catch (_a) { return false; } } function resolveWorkspaces(config, configFilePath, cwd) { const hasConfigWorkspaces = typeof config === "object" && config !== null && "workspaces" in config; if (hasConfigWorkspaces) { if (typeof config.workspaces === "boolean") { return config.workspaces; } throw new Error(`workspaces must be a boolean in config at ${configFilePath}`); } return detectWorkspacesFromPackageJson(cwd); } function applyDefaults(config, workspaces) { return { rootDir: config.rootDir, targets: config.targets || ["cursor"], presets: config.presets || [], mcpServers: config.mcpServers || {}, workspaces, skipInstall: config.skipInstall || false, }; } function validateConfig(config, configFilePath, cwd, isWorkspaceMode = false) { if (typeof config !== "object" || config === null) { throw new Error(`Config is not an object at ${configFilePath}`); } const unknownKeys = Object.keys(config).filter((key) => !exports.ALLOWED_CONFIG_KEYS.includes(key)); if (unknownKeys.length > 0) { throw new Error(`Invalid configuration at ${configFilePath}: unknown keys: ${unknownKeys.join(", ")}`); } // Validate rootDir const hasRootDir = "rootDir" in config && typeof config.rootDir === "string"; const hasPresets = "presets" in config && Array.isArray(config.presets) && config.presets.length > 0; if (hasRootDir) { const rootPath = node_path_1.default.resolve(cwd, config.rootDir); if (!fs_extra_1.default.existsSync(rootPath)) { throw new Error(`Root directory does not exist: ${rootPath}`); } if (!fs_extra_1.default.statSync(rootPath).isDirectory()) { throw new Error(`Root path is not a directory: ${rootPath}`); } // Check for at least one valid subdirectory or file const hasRules = fs_extra_1.default.existsSync(node_path_1.default.join(rootPath, "rules")); const hasCommands = fs_extra_1.default.existsSync(node_path_1.default.join(rootPath, "commands")); const hasHooks = fs_extra_1.default.existsSync(node_path_1.default.join(rootPath, "hooks.json")); const hasSkills = fs_extra_1.default.existsSync(node_path_1.default.join(rootPath, "skills")); const hasAgents = fs_extra_1.default.existsSync(node_path_1.default.join(rootPath, "agents")); // In workspace mode, root config doesn't need these directories // since packages will have their own configurations if (!isWorkspaceMode && !hasRules && !hasCommands && !hasHooks && !hasSkills && !hasAgents && !hasPresets) { throw new Error(`Root directory must contain at least one of: rules/, commands/, skills/, agents/, hooks.json, or have presets configured`); } } else if (!isWorkspaceMode && !hasPresets) { // If no rootDir specified and not in workspace mode, must have presets throw new Error(`At least one of rootDir or presets must be specified in config at ${configFilePath}`); } if ("targets" in config) { if (!Array.isArray(config.targets)) { throw new Error(`targets must be an array in config at ${configFilePath}`); } if (config.targets.length === 0) { throw new Error(`targets must not be empty in config at ${configFilePath}`); } for (const target of config.targets) { if (!exports.SUPPORTED_TARGETS.includes(target)) { throw new Error(`Unsupported target: ${target}. Supported targets: ${exports.SUPPORTED_TARGETS.join(", ")}`); } } } } async function loadRulesFromDirectory(directoryPath, source, presetName) { const rules = []; if (!fs_extra_1.default.existsSync(directoryPath)) { return rules; } const pattern = node_path_1.default.join(directoryPath, "**/*.mdc").replace(/\\/g, "/"); const filePaths = await (0, fast_glob_1.default)(pattern, { onlyFiles: true, absolute: true, }); for (const filePath of filePaths) { const content = await fs_extra_1.default.readFile(filePath, "utf8"); // Preserve directory structure by using relative path from source directory const relativePath = node_path_1.default.relative(directoryPath, filePath); const ruleName = relativePath.replace(/\.mdc$/, "").replace(/\\/g, "/"); rules.push({ name: ruleName, content, sourcePath: filePath, source, presetName, }); } return rules; } async function loadCommandsFromDirectory(directoryPath, source, presetName) { const commands = []; if (!fs_extra_1.default.existsSync(directoryPath)) { return commands; } const pattern = node_path_1.default.join(directoryPath, "**/*.md").replace(/\\/g, "/"); const filePaths = await (0, fast_glob_1.default)(pattern, { onlyFiles: true, absolute: true, }); filePaths.sort(); for (const filePath of filePaths) { const content = await fs_extra_1.default.readFile(filePath, "utf8"); const relativePath = node_path_1.default.relative(directoryPath, filePath); const commandName = relativePath.replace(/\.md$/, "").replace(/\\/g, "/"); commands.push({ name: commandName, content, sourcePath: filePath, source, presetName, }); } return commands; } async function loadAssetsFromDirectory(directoryPath, source, presetName) { const assets = []; if (!fs_extra_1.default.existsSync(directoryPath)) { return assets; } // Find all files except .mdc files and hidden files const pattern = node_path_1.default.join(directoryPath, "**/*").replace(/\\/g, "/"); const filePaths = await (0, fast_glob_1.default)(pattern, { onlyFiles: true, absolute: true, ignore: ["**/*.mdc", "**/.*"], }); for (const filePath of filePaths) { const content = await fs_extra_1.default.readFile(filePath); // Preserve directory structure by using relative path from source directory const relativePath = node_path_1.default.relative(directoryPath, filePath); // Keep extension for assets const assetName = relativePath.replace(/\\/g, "/"); assets.push({ name: assetName, content, sourcePath: filePath, source, presetName, }); } return assets; } /** * Load skills from a skills/ directory * Each direct subdirectory containing a SKILL.md file is considered a skill */ async function loadSkillsFromDirectory(directoryPath, source, presetName) { const skills = []; if (!fs_extra_1.default.existsSync(directoryPath)) { return skills; } // Get all direct subdirectories const entries = await fs_extra_1.default.readdir(directoryPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } const skillPath = node_path_1.default.join(directoryPath, entry.name); const skillMdPath = node_path_1.default.join(skillPath, "SKILL.md"); // Only include directories that contain a SKILL.md file if (!fs_extra_1.default.existsSync(skillMdPath)) { continue; } skills.push({ name: entry.name, sourcePath: skillPath, source, presetName, }); } return skills; } /** * Load agents from an agents/ directory * Agents are markdown files (.md) with YAML frontmatter */ async function loadAgentsFromDirectory(directoryPath, source, presetName) { const agents = []; if (!fs_extra_1.default.existsSync(directoryPath)) { return agents; } const pattern = node_path_1.default.join(directoryPath, "**/*.md").replace(/\\/g, "/"); const filePaths = await (0, fast_glob_1.default)(pattern, { onlyFiles: true, absolute: true, }); filePaths.sort(); for (const filePath of filePaths) { const content = await fs_extra_1.default.readFile(filePath, "utf8"); const relativePath = node_path_1.default.relative(directoryPath, filePath); const agentName = relativePath.replace(/\.md$/, "").replace(/\\/g, "/"); agents.push({ name: agentName, content, sourcePath: filePath, source, presetName, }); } return agents; } /** * Extract namespace from preset path for directory structure * Handles both npm packages and local paths consistently */ function extractNamespaceFromPresetPath(presetPath) { // Special case: npm package names always use forward slashes, regardless of platform if (presetPath.startsWith("@")) { // For scoped packages like @scope/package/subdir, create nested directories return presetPath.split("/"); } // Always split by forward slash since JSON config files use forward slashes on all platforms const parts = presetPath.split(node_path_1.default.posix.sep); return parts.filter((part) => part.length > 0 && part !== "." && part !== ".."); } function resolvePresetPath(presetPath, cwd) { // Support specifying aicm.json directory and load the config from it if (!presetPath.endsWith(".json")) { presetPath = node_path_1.default.join(presetPath, "aicm.json"); } // Support local or absolute paths const absolutePath = node_path_1.default.isAbsolute(presetPath) ? presetPath : node_path_1.default.resolve(cwd, presetPath); if (fs_extra_1.default.existsSync(absolutePath)) { return absolutePath; } try { // Support npm packages const resolvedPath = require.resolve(presetPath, { paths: [cwd, __dirname], }); return fs_extra_1.default.existsSync(resolvedPath) ? resolvedPath : null; } catch (_a) { return null; } } async function loadPreset(presetPath, cwd) { const resolvedPresetPath = resolvePresetPath(presetPath, cwd); if (!resolvedPresetPath) { throw new Error(`Preset not found: "${presetPath}". Make sure the package is installed or the path is correct.`); } let presetConfig; try { const content = await fs_extra_1.default.readFile(resolvedPresetPath, "utf8"); presetConfig = JSON.parse(content); } catch (error) { throw new Error(`Failed to load preset "${presetPath}": ${error instanceof Error ? error.message : "Unknown error"}`); } const presetDir = node_path_1.default.dirname(resolvedPresetPath); const presetRootDir = node_path_1.default.resolve(presetDir, presetConfig.rootDir || "./"); // Check if preset has content or inherits from other presets const hasRules = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "rules")); const hasCommands = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "commands")); const hasHooks = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "hooks.json")); const hasAssets = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "assets")); const hasSkills = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "skills")); const hasAgents = fs_extra_1.default.existsSync(node_path_1.default.join(presetRootDir, "agents")); const hasNestedPresets = Array.isArray(presetConfig.presets) && presetConfig.presets.length > 0; const hasAnyContent = hasRules || hasCommands || hasHooks || hasAssets || hasSkills || hasAgents || hasNestedPresets; if (!hasAnyContent) { throw new Error(`Preset "${presetPath}" must have at least one of: rules/, commands/, skills/, agents/, hooks.json, assets/, or presets`); } return { config: presetConfig, rootDir: presetRootDir, resolvedPath: resolvedPresetPath, }; } /** * Recursively load a preset and all its dependencies * @param presetPath The original preset path (used for namespacing) * @param cwd The current working directory for resolving paths * @param visited Set of already visited preset paths (by resolved absolute path) for cycle detection */ async function loadPresetRecursively(presetPath, cwd, visited) { const preset = await loadPreset(presetPath, cwd); const presetRootDir = preset.rootDir; const presetDir = node_path_1.default.dirname(preset.resolvedPath); // Check for circular dependency if (visited.has(preset.resolvedPath)) { throw new Error(`Circular preset dependency detected: "${presetPath}" has already been loaded`); } visited.add(preset.resolvedPath); const result = { rules: [], commands: [], assets: [], skills: [], agents: [], mcpServers: {}, hooksConfigs: [], hookFiles: [], }; // Load entities from this preset's rootDir const presetRulesPath = node_path_1.default.join(presetRootDir, "rules"); if (fs_extra_1.default.existsSync(presetRulesPath)) { const presetRules = await loadRulesFromDirectory(presetRulesPath, "preset", presetPath); result.rules.push(...presetRules); } const presetCommandsPath = node_path_1.default.join(presetRootDir, "commands"); if (fs_extra_1.default.existsSync(presetCommandsPath)) { const presetCommands = await loadCommandsFromDirectory(presetCommandsPath, "preset", presetPath); result.commands.push(...presetCommands); } const presetHooksFile = node_path_1.default.join(presetRootDir, "hooks.json"); if (fs_extra_1.default.existsSync(presetHooksFile)) { const { config: presetHooksConfig, files: presetHookFiles } = await (0, hooks_1.loadHooksFromDirectory)(presetRootDir, "preset", presetPath); result.hooksConfigs.push(presetHooksConfig); result.hookFiles.push(...presetHookFiles); } const presetAssetsPath = node_path_1.default.join(presetRootDir, "assets"); if (fs_extra_1.default.existsSync(presetAssetsPath)) { const presetAssets = await loadAssetsFromDirectory(presetAssetsPath, "preset", presetPath); result.assets.push(...presetAssets); } const presetSkillsPath = node_path_1.default.join(presetRootDir, "skills"); if (fs_extra_1.default.existsSync(presetSkillsPath)) { const presetSkills = await loadSkillsFromDirectory(presetSkillsPath, "preset", presetPath); result.skills.push(...presetSkills); } const presetAgentsPath = node_path_1.default.join(presetRootDir, "agents"); if (fs_extra_1.default.existsSync(presetAgentsPath)) { const presetAgents = await loadAgentsFromDirectory(presetAgentsPath, "preset", presetPath); result.agents.push(...presetAgents); } // Add MCP servers from this preset if (preset.config.mcpServers) { result.mcpServers = { ...preset.config.mcpServers }; } // Recursively load nested presets if (preset.config.presets && preset.config.presets.length > 0) { for (const nestedPresetPath of preset.config.presets) { const nestedResult = await loadPresetRecursively(nestedPresetPath, presetDir, // Use preset's directory as cwd for relative paths visited); // Merge results from nested preset result.rules.push(...nestedResult.rules); result.commands.push(...nestedResult.commands); result.assets.push(...nestedResult.assets); result.skills.push(...nestedResult.skills); result.agents.push(...nestedResult.agents); result.hooksConfigs.push(...nestedResult.hooksConfigs); result.hookFiles.push(...nestedResult.hookFiles); // Merge MCP servers (current preset takes precedence over nested) result.mcpServers = mergePresetMcpServers(result.mcpServers, nestedResult.mcpServers); } } return result; } async function loadAllRules(config, cwd) { const allRules = []; const allCommands = []; const allAssets = []; const allSkills = []; const allAgents = []; const allHookFiles = []; const allHooksConfigs = []; let mergedMcpServers = { ...config.mcpServers }; // Load local files from rootDir only if specified if (config.rootDir) { const rootPath = node_path_1.default.resolve(cwd, config.rootDir); // Load rules from rules/ subdirectory const rulesPath = node_path_1.default.join(rootPath, "rules"); if (fs_extra_1.default.existsSync(rulesPath)) { const localRules = await loadRulesFromDirectory(rulesPath, "local"); allRules.push(...localRules); } // Load commands from commands/ subdirectory const commandsPath = node_path_1.default.join(rootPath, "commands"); if (fs_extra_1.default.existsSync(commandsPath)) { const localCommands = await loadCommandsFromDirectory(commandsPath, "local"); allCommands.push(...localCommands); } // Load hooks from hooks.json (sibling to hooks/ directory) const hooksFilePath = node_path_1.default.join(rootPath, "hooks.json"); if (fs_extra_1.default.existsSync(hooksFilePath)) { const { config: localHooksConfig, files: localHookFiles } = await (0, hooks_1.loadHooksFromDirectory)(rootPath, "local"); allHooksConfigs.push(localHooksConfig); allHookFiles.push(...localHookFiles); } // Load assets from assets/ subdirectory const assetsPath = node_path_1.default.join(rootPath, "assets"); if (fs_extra_1.default.existsSync(assetsPath)) { const localAssets = await loadAssetsFromDirectory(assetsPath, "local"); allAssets.push(...localAssets); } // Load skills from skills/ subdirectory const skillsPath = node_path_1.default.join(rootPath, "skills"); if (fs_extra_1.default.existsSync(skillsPath)) { const localSkills = await loadSkillsFromDirectory(skillsPath, "local"); allSkills.push(...localSkills); } // Load agents from agents/ subdirectory const agentsPath = node_path_1.default.join(rootPath, "agents"); if (fs_extra_1.default.existsSync(agentsPath)) { const localAgents = await loadAgentsFromDirectory(agentsPath, "local"); allAgents.push(...localAgents); } } // Load presets recursively if (config.presets && config.presets.length > 0) { const visited = new Set(); for (const presetPath of config.presets) { const presetResult = await loadPresetRecursively(presetPath, cwd, visited); allRules.push(...presetResult.rules); allCommands.push(...presetResult.commands); allAssets.push(...presetResult.assets); allSkills.push(...presetResult.skills); allAgents.push(...presetResult.agents); allHooksConfigs.push(...presetResult.hooksConfigs); allHookFiles.push(...presetResult.hookFiles); // Merge MCP servers (local config takes precedence) mergedMcpServers = mergePresetMcpServers(mergedMcpServers, presetResult.mcpServers); } } // Merge all hooks configurations const mergedHooks = (0, hooks_1.mergeHooksConfigs)(allHooksConfigs); return { rules: allRules, commands: allCommands, assets: allAssets, skills: allSkills, agents: allAgents, mcpServers: mergedMcpServers, hooks: mergedHooks, hookFiles: allHookFiles, }; } /** * Merge preset MCP servers with local config MCP servers * Local config takes precedence over preset config */ function mergePresetMcpServers(configMcpServers, presetMcpServers) { const newMcpServers = { ...configMcpServers }; for (const [serverName, serverConfig] of Object.entries(presetMcpServers)) { // Cancel if set to false in config if (Object.prototype.hasOwnProperty.call(newMcpServers, serverName) && newMcpServers[serverName] === false) { delete newMcpServers[serverName]; continue; } // Only add if not already defined in config (local config takes precedence) if (!Object.prototype.hasOwnProperty.call(newMcpServers, serverName)) { newMcpServers[serverName] = serverConfig; } } return newMcpServers; } async function loadConfigFile(searchFrom) { const explorer = (0, cosmiconfig_1.cosmiconfig)("aicm", { searchPlaces: ["aicm.json", "package.json"], }); try { const result = await explorer.search(searchFrom); return result; } catch (error) { throw new Error(`Failed to load configuration: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Check if workspaces mode is enabled without loading all rules/presets * This is useful for commands that only need to know the workspace setting */ async function checkWorkspacesEnabled(cwd) { const workingDir = cwd || process.cwd(); const configResult = await loadConfigFile(workingDir); if (!(configResult === null || configResult === void 0 ? void 0 : configResult.config)) { return detectWorkspacesFromPackageJson(workingDir); } return resolveWorkspaces(configResult.config, configResult.filepath, workingDir); } async function loadConfig(cwd) { const workingDir = cwd || process.cwd(); const configResult = await loadConfigFile(workingDir); if (!(configResult === null || configResult === void 0 ? void 0 : configResult.config)) { return null; } const config = configResult.config; const isWorkspaces = resolveWorkspaces(config, configResult.filepath, workingDir); validateConfig(config, configResult.filepath, workingDir, isWorkspaces); const configWithDefaults = applyDefaults(config, isWorkspaces); const { rules, commands, assets, skills, agents, mcpServers, hooks, hookFiles, } = await loadAllRules(configWithDefaults, workingDir); return { config: configWithDefaults, rules, commands, assets, skills, agents, mcpServers, hooks, hookFiles, }; } function saveConfig(config, cwd) { const workingDir = cwd || process.cwd(); const configPath = node_path_1.default.join(workingDir, "aicm.json"); try { fs_extra_1.default.writeFileSync(configPath, JSON.stringify(config, null, 2)); return true; } catch (_a) { return false; } }