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