aicm
Version:
A TypeScript CLI tool for managing AI IDE rules across different projects and teams
583 lines (582 loc) • 24.9 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.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;
}
}