@intellectronica/ruler
Version:
Ruler — apply the same rules to all coding agents
385 lines (384 loc) • 15.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadUnifiedConfig = loadUnifiedConfig;
const fs_1 = require("fs");
const path = __importStar(require("path"));
const toml_1 = require("@iarna/toml");
const hash_1 = require("./hash");
const RuleProcessor_1 = require("./RuleProcessor");
const FileSystemUtils = __importStar(require("./FileSystemUtils"));
async function loadUnifiedConfig(options) {
// Resolve the effective .ruler directory (local or global), mirroring the main loader behavior
const resolvedRulerDir = (await FileSystemUtils.findRulerDir(options.projectRoot, true)) ||
path.join(options.projectRoot, '.ruler');
const meta = {
projectRoot: options.projectRoot,
rulerDir: resolvedRulerDir,
loadedAt: new Date(),
version: '0.0.0-dev',
};
const diagnostics = [];
// Read TOML if available
let tomlRaw = {};
const tomlFile = options.configPath
? path.resolve(options.configPath)
: path.join(meta.rulerDir, 'ruler.toml');
try {
const text = await fs_1.promises.readFile(tomlFile, 'utf8');
tomlRaw = text.trim() ? (0, toml_1.parse)(text) : {};
meta.configFile = tomlFile;
}
catch (err) {
if (err.code !== 'ENOENT') {
diagnostics.push({
severity: 'warning',
code: 'TOML_READ_ERROR',
message: 'Failed to read ruler.toml',
file: tomlFile,
detail: err.message,
});
}
}
let defaultAgents;
if (tomlRaw &&
typeof tomlRaw === 'object' &&
tomlRaw.default_agents &&
Array.isArray(tomlRaw.default_agents)) {
defaultAgents = tomlRaw.default_agents.map((a) => String(a));
}
let nested = false;
if (tomlRaw &&
typeof tomlRaw === 'object' &&
typeof tomlRaw.nested === 'boolean') {
nested = tomlRaw.nested;
}
// Parse skills configuration
let skillsConfig;
if (tomlRaw && typeof tomlRaw === 'object') {
const skillsSection = tomlRaw.skills;
if (skillsSection && typeof skillsSection === 'object') {
const skillsObj = skillsSection;
if (typeof skillsObj.enabled === 'boolean') {
skillsConfig = { enabled: skillsObj.enabled };
}
}
}
const toml = {
raw: tomlRaw,
schemaVersion: 1,
agents: {},
defaultAgents,
nested,
skills: skillsConfig,
};
// Collect rule markdown files
let ruleFiles = [];
try {
const dirEntries = await fs_1.promises.readdir(meta.rulerDir, { withFileTypes: true });
const mdFiles = dirEntries
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
.map((e) => path.join(meta.rulerDir, e.name));
// Sort lexicographically then ensure AGENTS.md first
mdFiles.sort((a, b) => a.localeCompare(b));
mdFiles.sort((a, b) => {
const aIs = /agents\.md$/i.test(a);
const bIs = /agents\.md$/i.test(b);
if (aIs && !bIs)
return -1;
if (bIs && !aIs)
return 1;
return 0;
});
let order = 0;
ruleFiles = await Promise.all(mdFiles.map(async (file) => {
const content = await fs_1.promises.readFile(file, 'utf8');
const stat = await fs_1.promises.stat(file);
return {
path: file,
relativePath: path.basename(file),
content,
contentHash: (0, hash_1.sha256)(content),
mtimeMs: stat.mtimeMs,
size: stat.size,
order: order++,
primary: /agents\.md$/i.test(file),
};
}));
}
catch (err) {
diagnostics.push({
severity: 'warning',
code: 'RULES_READ_ERROR',
message: 'Failed reading rule files',
file: meta.rulerDir,
detail: err.message,
});
}
const concatenated = (0, RuleProcessor_1.concatenateRules)(ruleFiles.map((f) => ({ path: f.path, content: f.content })), path.dirname(meta.rulerDir));
const rules = {
files: ruleFiles,
concatenated,
concatenatedHash: (0, hash_1.sha256)(concatenated),
};
// Parse TOML MCP servers
const tomlMcpServers = {};
if (tomlRaw && typeof tomlRaw === 'object') {
const tomlObj = tomlRaw;
if (tomlObj.mcp_servers && typeof tomlObj.mcp_servers === 'object') {
const mcpServersRaw = tomlObj.mcp_servers;
for (const [name, def] of Object.entries(mcpServersRaw)) {
if (!def || typeof def !== 'object')
continue;
const serverDef = def;
const server = {};
// Parse command and args
if (typeof serverDef.command === 'string') {
server.command = serverDef.command;
}
if (Array.isArray(serverDef.args)) {
server.args = serverDef.args.map(String);
}
// Parse env
if (serverDef.env && typeof serverDef.env === 'object') {
server.env = Object.fromEntries(Object.entries(serverDef.env).filter(([, v]) => typeof v === 'string'));
}
// Parse URL and headers
if (typeof serverDef.url === 'string') {
server.url = serverDef.url;
}
if (serverDef.headers && typeof serverDef.headers === 'object') {
server.headers = Object.fromEntries(Object.entries(serverDef.headers).filter(([, v]) => typeof v === 'string'));
}
if (typeof serverDef.timeout === 'number') {
server.timeout = serverDef.timeout;
}
// Validate server configuration
const hasCommand = !!server.command;
const hasUrl = !!server.url;
if (!hasCommand && !hasUrl) {
diagnostics.push({
severity: 'warning',
code: 'MCP_TOML_INVALID_SERVER',
message: `MCP server '${name}' must have at least one of command or url`,
file: tomlFile,
});
continue;
}
if (hasCommand && hasUrl) {
diagnostics.push({
severity: 'warning',
code: 'MCP_TOML_FIELD_CONFLICT',
message: `MCP server '${name}' has both command and url - using url (remote)`,
file: tomlFile,
});
}
if (hasCommand && server.headers) {
diagnostics.push({
severity: 'warning',
code: 'MCP_TOML_FIELD_CONFLICT',
message: `MCP server '${name}' has headers with command (should be used with url only)`,
file: tomlFile,
});
}
if (hasUrl && server.env) {
diagnostics.push({
severity: 'warning',
code: 'MCP_TOML_FIELD_CONFLICT',
message: `MCP server '${name}' has env with url (should be used with command only)`,
file: tomlFile,
});
}
// Derive type - remote takes precedence if both are present
if (server.url) {
server.type = 'remote';
}
else if (server.command) {
server.type = 'stdio';
}
tomlMcpServers[name] = server;
}
}
}
// Store TOML MCP servers in toml config
toml.mcpServers = tomlMcpServers;
// MCP normalization - merge JSON and TOML
let mcp = null;
const mcpFile = path.join(meta.rulerDir, 'mcp.json');
const jsonMcpServers = {};
let mcpJsonExists = false;
// Pre-flight existence check so users see warning even if JSON invalid
try {
await fs_1.promises.access(mcpFile);
mcpJsonExists = true;
// Warning is handled by apply-engine to avoid duplication
}
catch {
// file not present
}
// Add deprecation warning if mcp.json exists (regardless of validity)
if (mcpJsonExists) {
meta.mcpFile = mcpFile;
diagnostics.push({
severity: 'warning',
code: 'MCP_JSON_DEPRECATED',
message: 'mcp.json detected: please migrate MCP servers to ruler.toml [mcp_servers.*] sections',
file: mcpFile,
});
}
try {
if (mcpJsonExists) {
const raw = await fs_1.promises.readFile(mcpFile, 'utf8');
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (e) {
// Lenient fallback: strip comments and trailing commas then retry
const stripped = raw
// strip /* */ comments
.replace(/\/\*[\s\S]*?\*\//g, '')
// strip // comments
.replace(/(^|\s+)\/\/.*$/gm, '$1')
// remove trailing commas before } or ]
.replace(/,\s*([}\]])/g, '$1');
try {
parsed = JSON.parse(stripped);
}
catch {
throw e; // rethrow original error for diagnostics
}
}
const parsedObj = parsed;
const serversRaw = parsedObj.mcpServers ||
parsedObj.servers ||
{};
if (serversRaw && typeof serversRaw === 'object') {
for (const [name, def] of Object.entries(serversRaw)) {
if (!def || typeof def !== 'object')
continue;
const server = {};
if (typeof def.command === 'string')
server.command = def.command;
if (Array.isArray(def.command))
server.command = def.command[0];
if (Array.isArray(def.args))
server.args = def.args.map(String);
if (def.env && typeof def.env === 'object') {
server.env = Object.fromEntries(Object.entries(def.env).filter(([, v]) => typeof v === 'string'));
}
if (typeof def.url === 'string')
server.url = def.url;
if (def.headers && typeof def.headers === 'object') {
server.headers = Object.fromEntries(Object.entries(def.headers).filter(([, v]) => typeof v === 'string'));
}
if (typeof def.timeout === 'number') {
server.timeout = def.timeout;
}
// Derive type
if (server.url)
server.type = 'remote';
else if (server.command)
server.type = 'stdio';
jsonMcpServers[name] = server;
}
}
}
}
catch (err) {
if (mcpJsonExists) {
diagnostics.push({
severity: 'warning',
code: 'MCP_READ_ERROR',
message: 'Failed to read mcp.json',
file: mcpFile,
detail: err.message,
});
}
}
// Merge servers: start with JSON, overlay TOML (TOML wins per server name)
const mergedServers = { ...jsonMcpServers, ...tomlMcpServers };
// Create MCP bundle if we have any servers
if (Object.keys(mergedServers).length > 0 || mcpJsonExists) {
mcp = {
servers: mergedServers,
raw: mcpJsonExists ? { mcpServers: jsonMcpServers } : {},
hash: (0, hash_1.sha256)((0, hash_1.stableJson)(mergedServers)),
};
}
const config = {
meta,
toml,
rules,
mcp,
agents: {},
diagnostics,
hash: '', // placeholder, recompute after agents
};
// Agent resolution (basic): enabled set is CLI override or default_agents
const cliAgents = options.cliAgents && options.cliAgents.length > 0
? options.cliAgents
: undefined;
const enabledList = cliAgents ?? toml.defaultAgents ?? [];
for (const name of enabledList) {
config.agents[name] = {
identifier: name,
enabled: true,
output: {},
mcp: { enabled: false, strategy: 'merge' },
};
}
// If CLI provided, mark defaults not included as disabled (optional design choice)
if (cliAgents) {
for (const name of toml.defaultAgents ?? []) {
if (!config.agents[name]) {
config.agents[name] = {
identifier: name,
enabled: false,
output: {},
mcp: { enabled: false, strategy: 'merge' },
};
}
}
}
// Recompute hash including agents list
config.hash = (0, hash_1.sha256)((0, hash_1.stableJson)({
toml: toml.defaultAgents,
rules: rules.concatenatedHash,
mcp: mcp ? mcp.hash : null,
agents: Object.entries(config.agents).map(([k, v]) => [k, v.enabled]),
}));
return config;
}