UNPKG

@intellectronica/ruler

Version:

Ruler — apply the same rules to all coding agents

385 lines (384 loc) 15.2 kB
"use strict"; 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; }