UNPKG

aicm

Version:

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

305 lines (304 loc) 12.2 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.resolvePresetPath = resolvePresetPath; exports.loadPreset = loadPreset; exports.loadAllRules = loadAllRules; exports.applyOverrides = applyOverrides; exports.loadConfigFile = loadConfigFile; 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")); exports.ALLOWED_CONFIG_KEYS = [ "rulesDir", "targets", "presets", "overrides", "mcpServers", "workspaces", "skipInstall", ]; exports.SUPPORTED_TARGETS = ["cursor", "windsurf", "codex"]; 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 { rulesDir: config.rulesDir, targets: config.targets || ["cursor"], presets: config.presets || [], overrides: config.overrides || {}, 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 that either rulesDir or presets is provided const hasRulesDir = "rulesDir" in config && typeof config.rulesDir === "string"; const hasPresets = "presets" in config && Array.isArray(config.presets) && config.presets.length > 0; // In workspace mode, root config doesn't need rulesDir or presets // since packages will have their own configurations if (!isWorkspaceMode && !hasRulesDir && !hasPresets) { throw new Error(`Either rulesDir or presets must be specified in config at ${configFilePath}`); } // Validate rulesDir if provided if (hasRulesDir) { const rulesPath = node_path_1.default.resolve(cwd, config.rulesDir); if (!fs_extra_1.default.existsSync(rulesPath)) { throw new Error(`Rules directory does not exist: ${rulesPath}`); } if (!fs_extra_1.default.statSync(rulesPath).isDirectory()) { throw new Error(`Rules path is not a directory: ${rulesPath}`); } } 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(", ")}`); } } } // Validate override rule names will be checked after rule resolution } async function loadRulesFromDirectory(rulesDir, source, presetName) { const rules = []; if (!fs_extra_1.default.existsSync(rulesDir)) { return rules; } const pattern = node_path_1.default.join(rulesDir, "**/*.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 rulesDir const relativePath = node_path_1.default.relative(rulesDir, filePath); const ruleName = relativePath.replace(/\.mdc$/, "").replace(/\\/g, "/"); rules.push({ name: ruleName, content, sourcePath: filePath, source, presetName, }); } return rules; } 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"}`); } // Validate that preset has rulesDir if (!presetConfig.rulesDir) { throw new Error(`Preset "${presetPath}" must have a rulesDir specified`); } // Resolve preset's rules directory relative to the preset file const presetDir = node_path_1.default.dirname(resolvedPresetPath); const presetRulesDir = node_path_1.default.resolve(presetDir, presetConfig.rulesDir); return { config: presetConfig, rulesDir: presetRulesDir, }; } async function loadAllRules(config, cwd) { const allRules = []; let mergedMcpServers = { ...config.mcpServers }; // Load local rules only if rulesDir is provided if (config.rulesDir) { const localRulesPath = node_path_1.default.resolve(cwd, config.rulesDir); const localRules = await loadRulesFromDirectory(localRulesPath, "local"); allRules.push(...localRules); } if (config.presets) { for (const presetPath of config.presets) { const preset = await loadPreset(presetPath, cwd); const presetRules = await loadRulesFromDirectory(preset.rulesDir, "preset", presetPath); allRules.push(...presetRules); // Merge MCP servers from preset if (preset.config.mcpServers) { mergedMcpServers = mergePresetMcpServers(mergedMcpServers, preset.config.mcpServers); } } } return { rules: allRules, mcpServers: mergedMcpServers, }; } function applyOverrides(rules, overrides, cwd) { // Validate that all override rule names exist in the resolved rules for (const ruleName of Object.keys(overrides)) { // TODO: support better error messages with edit distance, helping the user in case of a typo // TODO: or shows a list of potential rules to override if (!rules.some((rule) => rule.name === ruleName)) { throw new Error(`Override rule "${ruleName}" does not exist in resolved rules`); } } const ruleMap = new Map(); for (const rule of rules) { ruleMap.set(rule.name, rule); } for (const [ruleName, override] of Object.entries(overrides)) { if (override === false) { ruleMap.delete(ruleName); } else if (typeof override === "string") { const overridePath = node_path_1.default.resolve(cwd, override); if (!fs_extra_1.default.existsSync(overridePath)) { throw new Error(`Override rule file not found: ${override} in ${cwd}`); } const content = fs_extra_1.default.readFileSync(overridePath, "utf8"); ruleMap.set(ruleName, { name: ruleName, content, sourcePath: overridePath, source: "local", }); } } return Array.from(ruleMap.values()); } /** * 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 (override handled by config) 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"}`); } } 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, mcpServers } = await loadAllRules(configWithDefaults, workingDir); let rulesWithOverrides = rules; if (configWithDefaults.overrides) { rulesWithOverrides = applyOverrides(rules, configWithDefaults.overrides, workingDir); } return { config: configWithDefaults, rules: rulesWithOverrides, mcpServers, }; } 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; } }