@intellectronica/ruler 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,7 @@ Ruler solves this by providing a **single source of truth** for all your AI agen
75
75
  | opencode | `AGENTS.md` | `opencode.json` |
76
76
  | Goose | `.goosehints` | - |
77
77
  | Qwen Code | `AGENTS.md` | `.qwen/settings.json` |
78
+ | RooCode | `AGENTS.md` | `.roo/mcp.json` |
78
79
  | Zed | `AGENTS.md` | `.zed/settings.json` (project root, never $HOME) |
79
80
  | Warp | `WARP.md` | - |
80
81
  | Kiro | `.kiro/steering/ruler_kiro_instructions.md` | - |
@@ -214,7 +215,7 @@ The `apply` command looks for `.ruler/` in the current directory tree, reading t
214
215
  | Option | Description |
215
216
  | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
216
217
  | `--project-root <path>` | Path to your project's root (default: current directory) |
217
- | `--agents <agent1,agent2,...>` | Comma-separated list of agent names to target (agentsmd, amp, copilot, claude, codex, cursor, windsurf, cline, aider, firebase, gemini-cli, junie, augmentcode, kilocode, warp) |
218
+ | `--agents <agent1,agent2,...>` | Comma-separated list of agent names to target (agentsmd, amp, copilot, claude, codex, cursor, windsurf, cline, aider, firebase, gemini-cli, junie, augmentcode, kilocode, warp, roo) |
218
219
  | `--config <path>` | Path to a custom `ruler.toml` configuration file |
219
220
  | `--mcp` / `--with-mcp` | Enable applying MCP server configurations (default: true) |
220
221
  | `--no-mcp` | Disable applying MCP server configurations |
@@ -250,6 +251,12 @@ ruler apply --agents firebase
250
251
  ruler apply --agents warp
251
252
  ```
252
253
 
254
+ **Apply rules only to RooCode:**
255
+
256
+ ```bash
257
+ ruler apply --agents roo
258
+ ```
259
+
253
260
  **Use a specific configuration file:**
254
261
 
255
262
  ```bash
@@ -66,14 +66,21 @@ class CodexCliAgent extends AgentsMdAgent_1.AgentsMdAgent {
66
66
  };
67
67
  const mcpEnabled = agentConfig?.mcp?.enabled ?? true;
68
68
  if (mcpEnabled && rulerMcpJson) {
69
+ // Apply MCP server filtering and transformation
70
+ const { filterMcpConfigForAgent } = await Promise.resolve().then(() => __importStar(require('../mcp/capabilities')));
71
+ const filteredMcpConfig = filterMcpConfigForAgent(rulerMcpJson, this);
72
+ if (!filteredMcpConfig) {
73
+ return; // No compatible servers found
74
+ }
75
+ const filteredRulerMcpJson = filteredMcpConfig;
69
76
  // Determine the config file path
70
77
  const configPath = agentConfig?.outputPathConfig ?? defaults.config;
71
78
  // Ensure the parent directory exists
72
79
  await fs_1.promises.mkdir(path.dirname(configPath), { recursive: true });
73
80
  // Get the merge strategy
74
81
  const strategy = agentConfig?.mcp?.strategy ?? 'merge';
75
- // Extract MCP servers from ruler config
76
- const rulerServers = rulerMcpJson.mcpServers || {};
82
+ // Extract MCP servers from filtered ruler config
83
+ const rulerServers = filteredRulerMcpJson.mcpServers || {};
77
84
  // Read existing TOML config if it exists
78
85
  let existingConfig = {};
79
86
  try {
@@ -106,6 +113,10 @@ class CodexCliAgent extends AgentsMdAgent_1.AgentsMdAgent {
106
113
  if (serverConfig.env) {
107
114
  mcpServer.env = serverConfig.env;
108
115
  }
116
+ // Handle additional properties from remote server transformation
117
+ if (serverConfig.headers) {
118
+ mcpServer.headers = serverConfig.headers;
119
+ }
109
120
  if (updatedConfig.mcp_servers) {
110
121
  updatedConfig.mcp_servers[serverName] = mcpServer;
111
122
  }
@@ -147,6 +158,20 @@ class CodexCliAgent extends AgentsMdAgent_1.AgentsMdAgent {
147
158
  }
148
159
  tomlContent += ` }\n`;
149
160
  }
161
+ // Add headers as inline table if present (from transformed remote servers)
162
+ if (serverConfig.headers &&
163
+ Object.keys(serverConfig.headers).length > 0) {
164
+ tomlContent += `headers = { `;
165
+ const entries = Object.entries(serverConfig.headers);
166
+ for (let i = 0; i < entries.length; i++) {
167
+ const [key, value] = entries[i];
168
+ tomlContent += `${JSON.stringify(key)} = "${value}"`;
169
+ if (i < entries.length - 1) {
170
+ tomlContent += ', ';
171
+ }
172
+ }
173
+ tomlContent += ` }\n`;
174
+ }
150
175
  }
151
176
  }
152
177
  await (0, FileSystemUtils_1.writeGeneratedFile)(configPath, tomlContent);
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RooCodeAgent = void 0;
37
+ const path = __importStar(require("path"));
38
+ const fs_1 = require("fs");
39
+ const AgentsMdAgent_1 = require("./AgentsMdAgent");
40
+ const FileSystemUtils_1 = require("../core/FileSystemUtils");
41
+ /**
42
+ * Agent for RooCode that writes to AGENTS.md and generates .roo/mcp.json
43
+ * with project-level MCP server configuration.
44
+ */
45
+ class RooCodeAgent {
46
+ constructor() {
47
+ this.agentsMdAgent = new AgentsMdAgent_1.AgentsMdAgent();
48
+ }
49
+ getIdentifier() {
50
+ return 'roo';
51
+ }
52
+ getName() {
53
+ return 'RooCode';
54
+ }
55
+ getDefaultOutputPath(projectRoot) {
56
+ return {
57
+ instructions: path.join(projectRoot, 'AGENTS.md'),
58
+ mcp: path.join(projectRoot, '.roo', 'mcp.json'),
59
+ };
60
+ }
61
+ async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, agentConfig, backup = true) {
62
+ // First perform idempotent AGENTS.md write via composed AgentsMdAgent
63
+ await this.agentsMdAgent.applyRulerConfig(concatenatedRules, projectRoot, null, {
64
+ // Preserve explicit outputPath precedence semantics if provided.
65
+ outputPath: agentConfig?.outputPath ||
66
+ agentConfig?.outputPathInstructions ||
67
+ undefined,
68
+ }, backup);
69
+ // Now handle .roo/mcp.json configuration
70
+ const outputPaths = this.getDefaultOutputPath(projectRoot);
71
+ const mcpPath = path.resolve(projectRoot, agentConfig?.outputPathConfig ?? outputPaths['mcp']);
72
+ await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(mcpPath));
73
+ // Create base structure with mcpServers
74
+ let finalMcpConfig = {
75
+ mcpServers: {},
76
+ };
77
+ // Try to read existing .roo/mcp.json
78
+ let existingConfig = {};
79
+ try {
80
+ const existingContent = await fs_1.promises.readFile(mcpPath, 'utf-8');
81
+ const parsed = JSON.parse(existingContent);
82
+ if (parsed && typeof parsed === 'object') {
83
+ existingConfig = parsed;
84
+ }
85
+ }
86
+ catch {
87
+ // File doesn't exist or invalid JSON - start fresh
88
+ existingConfig = {};
89
+ }
90
+ // Merge MCP servers if we have ruler config
91
+ if (rulerMcpJson?.mcpServers) {
92
+ const existingServers = existingConfig.mcpServers || {};
93
+ const newServers = rulerMcpJson.mcpServers;
94
+ // Shallow merge: new servers override existing with same name
95
+ finalMcpConfig = {
96
+ mcpServers: {
97
+ ...existingServers,
98
+ ...newServers,
99
+ },
100
+ };
101
+ }
102
+ else if (existingConfig.mcpServers) {
103
+ // Keep existing servers if no new ones to add
104
+ finalMcpConfig = {
105
+ mcpServers: existingConfig.mcpServers,
106
+ };
107
+ }
108
+ // If neither condition is met, finalMcpConfig remains { mcpServers: {} }
109
+ // Write the config file with pretty JSON (2 spaces)
110
+ const newContent = JSON.stringify(finalMcpConfig, null, 2);
111
+ // Check if content has changed for idempotency
112
+ let existingContent = null;
113
+ try {
114
+ existingContent = await fs_1.promises.readFile(mcpPath, 'utf8');
115
+ }
116
+ catch {
117
+ existingContent = null;
118
+ }
119
+ if (existingContent !== null && existingContent === newContent) {
120
+ // No change; skip backup/write for idempotency
121
+ return;
122
+ }
123
+ // Backup (only if file existed and backup is enabled) then write new content
124
+ if (backup) {
125
+ await (0, FileSystemUtils_1.backupFile)(mcpPath);
126
+ }
127
+ await (0, FileSystemUtils_1.writeGeneratedFile)(mcpPath, newContent);
128
+ }
129
+ supportsMcpStdio() {
130
+ return true;
131
+ }
132
+ supportsMcpRemote() {
133
+ return true;
134
+ }
135
+ getMcpServerKey() {
136
+ return 'mcpServers';
137
+ }
138
+ }
139
+ exports.RooCodeAgent = RooCodeAgent;
@@ -54,11 +54,32 @@ class WindsurfAgent extends AbstractAgent_1.AbstractAgent {
54
54
  // Windsurf expects a YAML front-matter block with a `trigger` flag.
55
55
  const frontMatter = ['---', 'trigger: always_on', '---', ''].join('\n');
56
56
  const content = `${frontMatter}${concatenatedRules.trimStart()}`;
57
+ const maxFileSize = 10000; // 10K characters
57
58
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(absolutePath));
58
59
  if (backup) {
59
60
  await (0, FileSystemUtils_1.backupFile)(absolutePath);
60
61
  }
61
- await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, content);
62
+ // Check if content exceeds the 10K limit
63
+ if (content.length <= maxFileSize) {
64
+ // Content fits in single file - use original behavior
65
+ await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, content);
66
+ }
67
+ else {
68
+ // Content exceeds limit - split into multiple files
69
+ console.warn(`[ruler] Warning: Windsurf rule content exceeds ${maxFileSize} characters (${content.length}). Splitting into multiple files.`);
70
+ const files = this.splitContentIntoFiles(concatenatedRules.trimStart(), frontMatter, maxFileSize);
71
+ // Write each split file
72
+ const rulesDir = path.dirname(absolutePath);
73
+ const baseName = path.basename(absolutePath, '.md');
74
+ for (let i = 0; i < files.length; i++) {
75
+ const fileName = `${baseName}_${i.toString().padStart(2, '0')}.md`;
76
+ const filePath = path.join(rulesDir, fileName);
77
+ if (backup) {
78
+ await (0, FileSystemUtils_1.backupFile)(filePath);
79
+ }
80
+ await (0, FileSystemUtils_1.writeGeneratedFile)(filePath, files[i]);
81
+ }
82
+ }
62
83
  }
63
84
  getDefaultOutputPath(projectRoot) {
64
85
  return path.join(projectRoot, '.windsurf', 'rules', 'ruler_windsurf_instructions.md');
@@ -69,5 +90,69 @@ class WindsurfAgent extends AbstractAgent_1.AbstractAgent {
69
90
  supportsMcpRemote() {
70
91
  return true;
71
92
  }
93
+ /**
94
+ * Gets all actual output paths that will be created, including split files.
95
+ * This allows the gitignore system to know about split files before they're created.
96
+ */
97
+ getActualOutputPaths(concatenatedRules, projectRoot, agentConfig) {
98
+ const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
99
+ const absolutePath = path.resolve(projectRoot, output);
100
+ // Windsurf expects a YAML front-matter block with a `trigger` flag.
101
+ const frontMatter = ['---', 'trigger: always_on', '---', ''].join('\n');
102
+ const content = `${frontMatter}${concatenatedRules.trimStart()}`;
103
+ const maxFileSize = 10000; // 10K characters
104
+ // Check if content will be split
105
+ if (content.length <= maxFileSize) {
106
+ // Content fits in single file
107
+ return [absolutePath];
108
+ }
109
+ else {
110
+ // Content will be split - calculate how many files will be created
111
+ const files = this.splitContentIntoFiles(concatenatedRules.trimStart(), frontMatter, maxFileSize);
112
+ const rulesDir = path.dirname(absolutePath);
113
+ const baseName = path.basename(absolutePath, '.md');
114
+ const splitPaths = [];
115
+ for (let i = 0; i < files.length; i++) {
116
+ const fileName = `${baseName}_${i.toString().padStart(2, '0')}.md`;
117
+ const filePath = path.join(rulesDir, fileName);
118
+ splitPaths.push(filePath);
119
+ }
120
+ return splitPaths;
121
+ }
122
+ }
123
+ /**
124
+ * Splits content into multiple files, each under the specified size limit.
125
+ * Splits at the closest newline within the limit.
126
+ * Each file gets its own front-matter.
127
+ */
128
+ splitContentIntoFiles(rules, frontMatter, maxFileSize) {
129
+ const files = [];
130
+ const availableSpace = maxFileSize - frontMatter.length;
131
+ let remainingRules = rules;
132
+ while (remainingRules.length > 0) {
133
+ if (remainingRules.length <= availableSpace) {
134
+ // Remaining content fits in one file
135
+ files.push(`${frontMatter}${remainingRules}`);
136
+ break;
137
+ }
138
+ // Find the last newline within the available space
139
+ let splitIndex = availableSpace;
140
+ const searchSpace = remainingRules.substring(0, availableSpace);
141
+ const lastNewline = searchSpace.lastIndexOf('\n');
142
+ if (lastNewline > 0) {
143
+ // Split at the newline (include the newline in the current file)
144
+ splitIndex = lastNewline + 1;
145
+ }
146
+ else {
147
+ // No newline found within limit - split at the limit
148
+ // This shouldn't happen often but we handle it gracefully
149
+ splitIndex = availableSpace;
150
+ }
151
+ const chunk = remainingRules.substring(0, splitIndex);
152
+ files.push(`${frontMatter}${chunk}`);
153
+ remainingRules = remainingRules.substring(splitIndex);
154
+ }
155
+ return files;
156
+ }
72
157
  }
73
158
  exports.WindsurfAgent = WindsurfAgent;
@@ -26,6 +26,7 @@ const AgentsMdAgent_1 = require("./AgentsMdAgent");
26
26
  const QwenCodeAgent_1 = require("./QwenCodeAgent");
27
27
  const KiroAgent_1 = require("./KiroAgent");
28
28
  const WarpAgent_1 = require("./WarpAgent");
29
+ const RooCodeAgent_1 = require("./RooCodeAgent");
29
30
  exports.allAgents = [
30
31
  new CopilotAgent_1.CopilotAgent(),
31
32
  new ClaudeAgent_1.ClaudeAgent(),
@@ -50,4 +51,5 @@ exports.allAgents = [
50
51
  new AgentsMdAgent_1.AgentsMdAgent(),
51
52
  new KiroAgent_1.KiroAgent(),
52
53
  new WarpAgent_1.WarpAgent(),
54
+ new RooCodeAgent_1.RooCodeAgent(),
53
55
  ];
@@ -263,7 +263,15 @@ async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJs
263
263
  (0, constants_1.logVerbose)(`Processing agent: ${agent.getName()}`, verbose);
264
264
  const agentConfig = config.agentConfigs[agent.getIdentifier()];
265
265
  // Collect output paths for .gitignore
266
- const outputPaths = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
266
+ let outputPaths;
267
+ // Special handling for Windsurf agent to account for file splitting
268
+ if (agent.getIdentifier() === 'windsurf' &&
269
+ 'getActualOutputPaths' in agent) {
270
+ outputPaths = agent.getActualOutputPaths(concatenatedRules, projectRoot, agentConfig);
271
+ }
272
+ else {
273
+ outputPaths = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
274
+ }
267
275
  (0, constants_1.logVerbose)(`Agent ${agent.getName()} output paths: ${outputPaths.join(', ')}`, verbose);
268
276
  generatedPaths.push(...outputPaths);
269
277
  // Only add the backup file paths to the gitignore list if backups are enabled
@@ -40,10 +40,24 @@ function filterMcpConfigForAgent(mcpConfig, agent) {
40
40
  const isStdio = hasCommand && !hasUrl;
41
41
  const isRemote = hasUrl && !hasCommand;
42
42
  // Include server if agent supports its type
43
- if ((isStdio && capabilities.supportsStdio) ||
44
- (isRemote && capabilities.supportsRemote)) {
43
+ if (isStdio && capabilities.supportsStdio) {
45
44
  filteredServers[serverName] = serverConfig;
46
45
  }
46
+ else if (isRemote && capabilities.supportsRemote) {
47
+ filteredServers[serverName] = serverConfig;
48
+ }
49
+ else if (isRemote &&
50
+ !capabilities.supportsRemote &&
51
+ capabilities.supportsStdio) {
52
+ // Transform remote server to stdio server using mcp-remote
53
+ const transformedConfig = {
54
+ command: 'npx',
55
+ args: ['-y', 'mcp-remote@latest', config.url],
56
+ ...Object.fromEntries(Object.entries(config).filter(([key]) => key !== 'url')),
57
+ };
58
+ filteredServers[serverName] = transformedConfig;
59
+ }
60
+ // Note: Mixed servers (both command and url) are excluded
47
61
  }
48
62
  return Object.keys(filteredServers).length > 0
49
63
  ? { mcpServers: filteredServers }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellectronica/ruler",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Ruler — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "scripts": {