@intellectronica/ruler 0.3.0 → 0.3.2

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
@@ -38,12 +38,14 @@ Managing instructions across multiple AI coding tools becomes complex as your te
38
38
  - **Duplicated effort** maintaining multiple config files
39
39
  - **Context drift** as project requirements evolve
40
40
  - **Onboarding friction** for new AI tools
41
+ - **Complex project structures** requiring context-specific instructions for different components
41
42
 
42
- Ruler solves this by providing a **single source of truth** for all your AI agent instructions, automatically distributing them to the right configuration files.
43
+ Ruler solves this by providing a **single source of truth** for all your AI agent instructions, automatically distributing them to the right configuration files. With support for **nested rule loading**, Ruler can handle complex project structures with context-specific instructions for different components.
43
44
 
44
45
  ## Core Features
45
46
 
46
47
  - **Centralised Rule Management**: Store all AI instructions in a dedicated `.ruler/` directory using Markdown files
48
+ - **Nested Rule Loading**: Support complex project structures with multiple `.ruler/` directories for context-specific instructions
47
49
  - **Automatic Distribution**: Ruler applies these rules to configuration files of supported AI agents
48
50
  - **Targeted Agent Configuration**: Fine-tune which agents are affected and their specific output paths via `ruler.toml`
49
51
  - **MCP Server Propagation**: Manage and distribute Model Context Protocol (MCP) server settings
@@ -52,30 +54,30 @@ Ruler solves this by providing a **single source of truth** for all your AI agen
52
54
 
53
55
  ## Supported AI Agents
54
56
 
55
- | Agent | Rules File(s) | MCP Configuration / Notes |
56
- | ---------------- | ------------------------------------------------ | --------------------------------------------------- |
57
- | AGENTS.md | `AGENTS.md` | (pseudo-agent ensuring root `AGENTS.md` exists) |
58
- | GitHub Copilot | `.github/copilot-instructions.md` | `.vscode/mcp.json` |
59
- | Claude Code | `CLAUDE.md` | `.mcp.json` |
60
- | OpenAI Codex CLI | `AGENTS.md` | `.codex/config.toml` |
61
- | Jules | `AGENTS.md` | - |
62
- | Cursor | `.cursor/rules/ruler_cursor_instructions.mdc` | `.cursor/mcp.json` |
63
- | Windsurf | `.windsurf/rules/ruler_windsurf_instructions.md` | - |
64
- | Cline | `.clinerules` | - |
65
- | Amp | `AGENTS.md` | - |
66
- | Aider | `AGENTS.md`, `.aider.conf.yml` | `.mcp.json` |
67
- | Firebase Studio | `.idx/airules.md` | - |
68
- | Open Hands | `.openhands/microagents/repo.md` | `.openhands/config.toml` |
69
- | Gemini CLI | `AGENTS.md` | `.gemini/settings.json` |
70
- | Junie | `.junie/guidelines.md` | - |
71
- | AugmentCode | `.augment/rules/ruler_augment_instructions.md` | `.vscode/settings.json` |
72
- | Kilo Code | `.kilocode/rules/ruler_kilocode_instructions.md` | `.kilocode/mcp.json` |
73
- | opencode | `AGENTS.md` | `opencode.json` |
74
- | Goose | `.goosehints` | - |
75
- | Qwen Code | `AGENTS.md` | `.qwen/settings.json` |
76
- | Zed | `AGENTS.md` | `.zed/settings.json` (project root, never $HOME) |
77
- | Warp | `WARP.md` | - |
78
- | Kiro | `.kiro/steering/ruler_kiro_instructions.md` | - |
57
+ | Agent | Rules File(s) | MCP Configuration / Notes |
58
+ | ---------------- | ------------------------------------------------ | ------------------------------------------------ |
59
+ | AGENTS.md | `AGENTS.md` | (pseudo-agent ensuring root `AGENTS.md` exists) |
60
+ | GitHub Copilot | `.github/copilot-instructions.md` | `.vscode/mcp.json` |
61
+ | Claude Code | `CLAUDE.md` | `.mcp.json` |
62
+ | OpenAI Codex CLI | `AGENTS.md` | `.codex/config.toml` |
63
+ | Jules | `AGENTS.md` | - |
64
+ | Cursor | `.cursor/rules/ruler_cursor_instructions.mdc` | `.cursor/mcp.json` |
65
+ | Windsurf | `.windsurf/rules/ruler_windsurf_instructions.md` | - |
66
+ | Cline | `.clinerules` | - |
67
+ | Amp | `AGENTS.md` | - |
68
+ | Aider | `AGENTS.md`, `.aider.conf.yml` | `.mcp.json` |
69
+ | Firebase Studio | `.idx/airules.md` | - |
70
+ | Open Hands | `.openhands/microagents/repo.md` | `.openhands/config.toml` |
71
+ | Gemini CLI | `AGENTS.md` | `.gemini/settings.json` |
72
+ | Junie | `.junie/guidelines.md` | - |
73
+ | AugmentCode | `.augment/rules/ruler_augment_instructions.md` | `.vscode/settings.json` |
74
+ | Kilo Code | `.kilocode/rules/ruler_kilocode_instructions.md` | `.kilocode/mcp.json` |
75
+ | opencode | `AGENTS.md` | `opencode.json` |
76
+ | Goose | `.goosehints` | - |
77
+ | Qwen Code | `AGENTS.md` | `.qwen/settings.json` |
78
+ | Zed | `AGENTS.md` | `.zed/settings.json` (project root, never $HOME) |
79
+ | Warp | `WARP.md` | - |
80
+ | Kiro | `.kiro/steering/ruler_kiro_instructions.md` | - |
79
81
 
80
82
  ## Getting Started
81
83
 
@@ -133,6 +135,39 @@ This is your central hub for all AI agent instructions:
133
135
 
134
136
  This ordering lets you keep a short, high-impact root `AGENTS.md` (e.g. executive project summary) while housing detailed guidance inside `.ruler/`.
135
137
 
138
+ ### Nested Rule Loading
139
+
140
+ Ruler now supports **nested rule loading** with the `--nested` flag, enabling context-specific instructions for different parts of your project:
141
+
142
+ ```
143
+ project/
144
+ ├── .ruler/ # Global project rules
145
+ │ ├── AGENTS.md
146
+ │ └── coding_style.md
147
+ ├── src/
148
+ │ └── .ruler/ # Component-specific rules
149
+ │ └── api_guidelines.md
150
+ ├── tests/
151
+ │ └── .ruler/ # Test-specific rules
152
+ │ └── testing_conventions.md
153
+ └── docs/
154
+ └── .ruler/ # Documentation rules
155
+ └── writing_style.md
156
+ ```
157
+
158
+ **How it works:**
159
+
160
+ - Discover all `.ruler/` directories in the project hierarchy
161
+ - Load and concatenate rules from each directory in order
162
+ - Enable with: `ruler apply --nested`
163
+
164
+ **Perfect for:**
165
+
166
+ - Monorepos with multiple services
167
+ - Projects with distinct components (frontend/backend)
168
+ - Teams needing different instructions for different areas
169
+ - Complex codebases with varying standards
170
+
136
171
  ### Best Practices for Rule Files
137
172
 
138
173
  **Granularity**: Break down complex instructions into focused `.md` files:
@@ -176,18 +211,18 @@ The `apply` command looks for `.ruler/` in the current directory tree, reading t
176
211
 
177
212
  ### Options
178
213
 
179
- | Option | Description |
180
- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
181
- | `--project-root <path>` | Path to your project's root (default: current directory) |
214
+ | Option | Description |
215
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
216
+ | `--project-root <path>` | Path to your project's root (default: current directory) |
182
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) |
183
- | `--config <path>` | Path to a custom `ruler.toml` configuration file |
184
- | `--mcp` / `--with-mcp` | Enable applying MCP server configurations (default: true) |
185
- | `--no-mcp` | Disable applying MCP server configurations |
186
- | `--mcp-overwrite` | Overwrite native MCP config entirely instead of merging |
187
- | `--gitignore` | Enable automatic .gitignore updates (default: true) |
188
- | `--no-gitignore` | Disable automatic .gitignore updates |
189
- | `--local-only` | Do not look for configuration in `$XDG_CONFIG_HOME` |
190
- | `--verbose` / `-v` | Display detailed output during execution |
218
+ | `--config <path>` | Path to a custom `ruler.toml` configuration file |
219
+ | `--mcp` / `--with-mcp` | Enable applying MCP server configurations (default: true) |
220
+ | `--no-mcp` | Disable applying MCP server configurations |
221
+ | `--mcp-overwrite` | Overwrite native MCP config entirely instead of merging |
222
+ | `--gitignore` | Enable automatic .gitignore updates (default: true) |
223
+ | `--no-gitignore` | Disable automatic .gitignore updates |
224
+ | `--local-only` | Do not look for configuration in `$XDG_CONFIG_HOME` |
225
+ | `--verbose` / `-v` | Display detailed output during execution |
191
226
 
192
227
  ### Common Examples
193
228
 
@@ -254,15 +289,15 @@ ruler revert [options]
254
289
 
255
290
  ### Options
256
291
 
257
- | Option | Description |
258
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
259
- | `--project-root <path>` | Path to your project's root (default: current directory) |
292
+ | Option | Description |
293
+ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
294
+ | `--project-root <path>` | Path to your project's root (default: current directory) |
260
295
  | `--agents <agent1,agent2,...>` | Comma-separated list of agent names to revert (agentsmd, amp, copilot, claude, codex, cursor, windsurf, cline, aider, firebase, gemini-cli, junie, kilocode, opencode, warp) |
261
- | `--config <path>` | Path to a custom `ruler.toml` configuration file |
262
- | `--keep-backups` | Keep backup files (.bak) after restoration (default: false) |
263
- | `--dry-run` | Preview changes without actually reverting files |
264
- | `--verbose` / `-v` | Display detailed output during execution |
265
- | `--local-only` | Only search for local .ruler directories, ignore global config |
296
+ | `--config <path>` | Path to a custom `ruler.toml` configuration file |
297
+ | `--keep-backups` | Keep backup files (.bak) after restoration (default: false) |
298
+ | `--dry-run` | Preview changes without actually reverting files |
299
+ | `--verbose` / `-v` | Display detailed output during execution |
300
+ | `--local-only` | Only search for local .ruler directories, ignore global config |
266
301
 
267
302
  ### Common Examples
268
303
 
@@ -322,7 +357,7 @@ command = "npx"
322
357
  args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
323
358
 
324
359
  [mcp_servers.git]
325
- command = "npx"
360
+ command = "npx"
326
361
  args = ["-y", "@modelcontextprotocol/server-git", "--repository", "."]
327
362
 
328
363
  [mcp_servers.remote_api]
@@ -456,6 +491,7 @@ For backward compatibility, you can still use the JSON format; a warning is issu
456
491
  ### Configuration Precedence
457
492
 
458
493
  When both TOML and JSON configurations are present:
494
+
459
495
  1. **TOML servers take precedence** over JSON servers with the same name
460
496
  2. **Servers are merged** from both sources (unless using overwrite strategy)
461
497
  3. **Deprecation warning** is shown encouraging migration to TOML (warning shown once per run)
@@ -463,6 +499,7 @@ When both TOML and JSON configurations are present:
463
499
  ### Server Types
464
500
 
465
501
  **Local/stdio servers** require a `command` field:
502
+
466
503
  ```toml
467
504
  [mcp_servers.local_server]
468
505
  command = "node"
@@ -474,7 +511,7 @@ DEBUG = "1"
474
511
 
475
512
  **Remote servers** require a `url` field (headers optional; bearer Authorization token auto-extracted for OpenHands when possible):
476
513
  ```toml
477
- [mcp_servers.remote_server]
514
+ [mcp_servers.remote_server]
478
515
  url = "https://api.example.com"
479
516
 
480
517
  [mcp_servers.remote_server.headers]
@@ -486,6 +523,7 @@ Ruler uses this configuration with the `merge` (default) or `overwrite` strategy
486
523
  **Home Directory Safety:** Ruler never writes MCP configuration files outside your project root. Any historical references to user home directories (e.g. `~/.codeium/windsurf/mcp_config.json` or `~/.zed/settings.json`) have been removed; only project-local paths are targeted.
487
524
 
488
525
  **Note for OpenAI Codex CLI:** To apply the local Codex CLI MCP configuration, set the `CODEX_HOME` environment variable to your project’s `.codex` directory:
526
+
489
527
  ```bash
490
528
  export CODEX_HOME="$(pwd)/.codex"
491
529
  ```
@@ -545,7 +583,26 @@ ruler init
545
583
  ruler apply
546
584
  ```
547
585
 
548
- ### Scenario 2: Team Standardization
586
+ ### Scenario 2: Complex Projects with Nested Rules
587
+
588
+ For large projects with multiple components or services, use nested rule loading:
589
+
590
+ ```bash
591
+ # Set up nested .ruler directories
592
+ mkdir -p src/.ruler tests/.ruler docs/.ruler
593
+
594
+ # Add component-specific instructions
595
+ echo "# API Design Guidelines" > src/.ruler/api_rules.md
596
+ echo "# Testing Best Practices" > tests/.ruler/test_rules.md
597
+ echo "# Documentation Standards" > docs/.ruler/docs_rules.md
598
+
599
+ # Apply with nested loading
600
+ ruler apply --nested --verbose
601
+ ```
602
+
603
+ This creates context-specific instructions for different parts of your project while maintaining global rules in the root `.ruler/` directory.
604
+
605
+ ### Scenario 3: Team Standardization
549
606
 
550
607
  1. Create `.ruler/coding_standards.md`, `.ruler/api_usage.md`
551
608
  2. Commit the `.ruler` directory to your repository
@@ -647,6 +704,9 @@ This shows:
647
704
  **Q: Can I use different rules for different agents?**
648
705
  A: Currently, all agents receive the same concatenated rules. For agent-specific instructions, include sections in your rule files like "## GitHub Copilot Specific" or "## Aider Configuration".
649
706
 
707
+ **Q: How do I set up different instructions for different parts of my project?**
708
+ A: Use the `--nested` flag with `ruler apply --nested`. This enables Ruler to discover and load rules from multiple `.ruler/` directories throughout your project hierarchy. Place component-specific instructions in `src/.ruler/`, test-specific rules in `tests/.ruler/`, etc., while keeping global rules in the root `.ruler/` directory.
709
+
650
710
  **Q: How do I temporarily disable Ruler for an agent?**
651
711
  A: Set `enabled = false` in `ruler.toml` under `[agents.agentname]`, or use `--agents` flag to specify only the agents you want.
652
712
 
@@ -50,11 +50,13 @@ class AbstractAgent {
50
50
  * 4. Writing the new content
51
51
  */
52
52
  async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, // eslint-disable-line @typescript-eslint/no-unused-vars
53
- agentConfig) {
53
+ agentConfig, backup = true) {
54
54
  const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
55
55
  const absolutePath = path.resolve(projectRoot, output);
56
56
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(absolutePath));
57
- await (0, FileSystemUtils_1.backupFile)(absolutePath);
57
+ if (backup) {
58
+ await (0, FileSystemUtils_1.backupFile)(absolutePath);
59
+ }
58
60
  await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, concatenatedRules);
59
61
  }
60
62
  /**
@@ -54,10 +54,12 @@ class AgentsMdAgent extends AbstractAgent_1.AbstractAgent {
54
54
  return path.join(projectRoot, 'AGENTS.md');
55
55
  }
56
56
  async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, // eslint-disable-line @typescript-eslint/no-unused-vars
57
- agentConfig) {
57
+ agentConfig, backup = true) {
58
58
  const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
59
59
  const absolutePath = path.resolve(projectRoot, output);
60
60
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(absolutePath));
61
+ // Add marker comment to the content to identify it as generated
62
+ const contentWithMarker = `<!-- Generated by Ruler -->\n${concatenatedRules}`;
61
63
  // Read existing content if present and skip write if identical
62
64
  let existing = null;
63
65
  try {
@@ -66,13 +68,15 @@ class AgentsMdAgent extends AbstractAgent_1.AbstractAgent {
66
68
  catch {
67
69
  existing = null;
68
70
  }
69
- if (existing !== null && existing === concatenatedRules) {
71
+ if (existing !== null && existing === contentWithMarker) {
70
72
  // No change; skip backup/write for idempotency
71
73
  return;
72
74
  }
73
- // Backup (only if file existed) then write new content
74
- await (0, FileSystemUtils_1.backupFile)(absolutePath);
75
- await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, concatenatedRules);
75
+ // Backup (only if file existed and backup is enabled) then write new content
76
+ if (backup) {
77
+ await (0, FileSystemUtils_1.backupFile)(absolutePath);
78
+ }
79
+ await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, contentWithMarker);
76
80
  }
77
81
  getMcpServerKey() {
78
82
  // No MCP configuration for this pseudo-agent
@@ -52,21 +52,23 @@ class AiderAgent {
52
52
  getName() {
53
53
  return 'Aider';
54
54
  }
55
- async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, agentConfig) {
55
+ async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, agentConfig, backup = true) {
56
56
  // First perform idempotent AGENTS.md write via composed AgentsMdAgent
57
57
  await this.agentsMdAgent.applyRulerConfig(concatenatedRules, projectRoot, null, {
58
58
  // Preserve explicit outputPath precedence semantics if provided.
59
59
  outputPath: agentConfig?.outputPath ||
60
60
  agentConfig?.outputPathInstructions ||
61
61
  undefined,
62
- });
62
+ }, backup);
63
63
  // Now handle .aider.conf.yml configuration
64
64
  const cfgPath = agentConfig?.outputPathConfig ??
65
65
  this.getDefaultOutputPath(projectRoot).config;
66
66
  let doc = {};
67
67
  try {
68
68
  await fs.access(cfgPath);
69
- await (0, FileSystemUtils_1.backupFile)(cfgPath);
69
+ if (backup) {
70
+ await (0, FileSystemUtils_1.backupFile)(cfgPath);
71
+ }
70
72
  const raw = await fs.readFile(cfgPath, 'utf8');
71
73
  doc = (yaml.load(raw) || {});
72
74
  }
@@ -48,9 +48,11 @@ class AugmentCodeAgent {
48
48
  return 'AugmentCode';
49
49
  }
50
50
  async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, // eslint-disable-line @typescript-eslint/no-unused-vars
51
- agentConfig) {
51
+ agentConfig, backup = true) {
52
52
  const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
53
- await (0, FileSystemUtils_1.backupFile)(output);
53
+ if (backup) {
54
+ await (0, FileSystemUtils_1.backupFile)(output);
55
+ }
54
56
  await (0, FileSystemUtils_1.writeGeneratedFile)(output, concatenatedRules);
55
57
  // AugmentCode does not support MCP servers
56
58
  // MCP configuration is ignored for this agent
@@ -48,7 +48,7 @@ class CursorAgent extends AbstractAgent_1.AbstractAgent {
48
48
  return 'Cursor';
49
49
  }
50
50
  async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, // eslint-disable-line @typescript-eslint/no-unused-vars
51
- agentConfig) {
51
+ agentConfig, backup = true) {
52
52
  const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
53
53
  const absolutePath = path.resolve(projectRoot, output);
54
54
  // Cursor expects a YAML front-matter block with an `alwaysApply` flag.
@@ -56,7 +56,9 @@ class CursorAgent extends AbstractAgent_1.AbstractAgent {
56
56
  const frontMatter = ['---', 'alwaysApply: true', '---', ''].join('\n');
57
57
  const content = `${frontMatter}${concatenatedRules.trimStart()}`;
58
58
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(absolutePath));
59
- await (0, FileSystemUtils_1.backupFile)(absolutePath);
59
+ if (backup) {
60
+ await (0, FileSystemUtils_1.backupFile)(absolutePath);
61
+ }
60
62
  await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, content);
61
63
  }
62
64
  getDefaultOutputPath(projectRoot) {
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.WindsurfAgent = void 0;
37
37
  const path = __importStar(require("path"));
38
38
  const AbstractAgent_1 = require("./AbstractAgent");
39
+ const FileSystemUtils_1 = require("../core/FileSystemUtils");
39
40
  /**
40
41
  * Windsurf agent adapter.
41
42
  */
@@ -46,6 +47,19 @@ class WindsurfAgent extends AbstractAgent_1.AbstractAgent {
46
47
  getName() {
47
48
  return 'Windsurf';
48
49
  }
50
+ async applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, // eslint-disable-line @typescript-eslint/no-unused-vars
51
+ agentConfig, backup = true) {
52
+ const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot);
53
+ const absolutePath = path.resolve(projectRoot, output);
54
+ // Windsurf expects a YAML front-matter block with a `trigger` flag.
55
+ const frontMatter = ['---', 'trigger: always_on', '---', ''].join('\n');
56
+ const content = `${frontMatter}${concatenatedRules.trimStart()}`;
57
+ await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(absolutePath));
58
+ if (backup) {
59
+ await (0, FileSystemUtils_1.backupFile)(absolutePath);
60
+ }
61
+ await (0, FileSystemUtils_1.writeGeneratedFile)(absolutePath, content);
62
+ }
49
63
  getDefaultOutputPath(projectRoot) {
50
64
  return path.join(projectRoot, '.windsurf', 'rules', 'ruler_windsurf_instructions.md');
51
65
  }
@@ -59,6 +59,16 @@ function run() {
59
59
  type: 'boolean',
60
60
  description: 'Only search for local .ruler directories, ignore global config',
61
61
  default: false,
62
+ })
63
+ .option('nested', {
64
+ type: 'boolean',
65
+ description: 'Enable nested rule loading from nested .ruler directories (default: disabled)',
66
+ default: false,
67
+ })
68
+ .option('backup', {
69
+ type: 'boolean',
70
+ description: 'Enable/disable creation of .bak backup files (default: enabled)',
71
+ default: true,
62
72
  });
63
73
  }, handlers_1.applyHandler)
64
74
  .command('init', 'Scaffold a .ruler directory with default files', (y) => {
@@ -58,6 +58,8 @@ async function applyHandler(argv) {
58
58
  const verbose = argv.verbose;
59
59
  const dryRun = argv['dry-run'];
60
60
  const localOnly = argv['local-only'];
61
+ const nested = argv.nested;
62
+ const backup = argv.backup;
61
63
  // Determine gitignore preference: CLI > TOML > Default (enabled)
62
64
  // yargs handles --no-gitignore by setting gitignore to false
63
65
  let gitignorePreference;
@@ -68,7 +70,7 @@ async function applyHandler(argv) {
68
70
  gitignorePreference = undefined; // Let TOML/default decide
69
71
  }
70
72
  try {
71
- await (0, lib_1.applyAllAgentConfigs)(projectRoot, agents, configPath, mcpEnabled, mcpStrategy, gitignorePreference, verbose, dryRun, localOnly);
73
+ await (0, lib_1.applyAllAgentConfigs)(projectRoot, agents, configPath, mcpEnabled, mcpStrategy, gitignorePreference, verbose, dryRun, localOnly, nested, backup);
72
74
  console.log('Ruler apply completed successfully.');
73
75
  }
74
76
  catch (err) {
@@ -106,6 +108,10 @@ async function initHandler(argv) {
106
108
  # uncomment and populate the following line. If omitted, all agents are active.
107
109
  # default_agents = ["copilot", "claude"]
108
110
 
111
+ # Enable nested rule loading from nested .ruler directories
112
+ # When enabled, ruler will search for and process .ruler directories throughout the project hierarchy
113
+ # nested = false
114
+
109
115
  # --- Agent Specific Configurations ---
110
116
  # You can enable/disable agents and override their default output paths here.
111
117
  # Use lowercase agent identifiers: amp, copilot, claude, codex, cursor, windsurf, cline, aider, kilocode
@@ -69,6 +69,7 @@ const rulerConfigSchema = zod_1.z.object({
69
69
  enabled: zod_1.z.boolean().optional(),
70
70
  })
71
71
  .optional(),
72
+ nested: zod_1.z.boolean().optional(),
72
73
  });
73
74
  /**
74
75
  * Loads and parses the ruler TOML configuration file, applying defaults.
@@ -174,11 +175,13 @@ async function loadConfig(options) {
174
175
  if (typeof rawGitignoreSection.enabled === 'boolean') {
175
176
  gitignoreConfig.enabled = rawGitignoreSection.enabled;
176
177
  }
178
+ const nested = typeof raw.nested === 'boolean' ? raw.nested : false;
177
179
  return {
178
180
  defaultAgents,
179
181
  agentConfigs,
180
182
  cliAgents,
181
183
  mcp: globalMcpConfig,
182
184
  gitignore: gitignoreConfig,
185
+ nested,
183
186
  };
184
187
  }
@@ -38,6 +38,8 @@ exports.readMarkdownFiles = readMarkdownFiles;
38
38
  exports.writeGeneratedFile = writeGeneratedFile;
39
39
  exports.backupFile = backupFile;
40
40
  exports.ensureDirExists = ensureDirExists;
41
+ exports.findGlobalRulerDir = findGlobalRulerDir;
42
+ exports.findAllRulerDirs = findAllRulerDirs;
41
43
  const fs_1 = require("fs");
42
44
  const path = __importStar(require("path"));
43
45
  const os = __importStar(require("os"));
@@ -147,8 +149,19 @@ async function readMarkdownFiles(rulerDir) {
147
149
  const stat = await fs_1.promises.stat(rootAgentsPath);
148
150
  if (stat.isFile()) {
149
151
  const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
150
- // Prepend so it has highest precedence
151
- ordered = [{ path: rootAgentsPath, content }, ...ordered];
152
+ // Check if this is a generated file and we have other .ruler files
153
+ const isGenerated = content.startsWith('<!-- Generated by Ruler -->');
154
+ const hasRulerFiles = others.length > 0 || primaryFile !== null;
155
+ // Additional check: if AGENTS.md contains ruler source comments and we have ruler files,
156
+ // it's likely a corrupted generated file that should be skipped
157
+ const containsRulerSources = content.includes('<!-- Source: .ruler/') ||
158
+ content.includes('<!-- Source: ruler/');
159
+ const isProbablyGenerated = isGenerated || (containsRulerSources && hasRulerFiles);
160
+ // Skip generated AGENTS.md if we have other files in .ruler
161
+ if (!isProbablyGenerated || !hasRulerFiles) {
162
+ // Prepend so it has highest precedence
163
+ ordered = [{ path: rootAgentsPath, content }, ...ordered];
164
+ }
152
165
  }
153
166
  }
154
167
  }
@@ -182,3 +195,62 @@ async function backupFile(filePath) {
182
195
  async function ensureDirExists(dirPath) {
183
196
  await fs_1.promises.mkdir(dirPath, { recursive: true });
184
197
  }
198
+ /**
199
+ * Finds the global ruler configuration directory at XDG_CONFIG_HOME/ruler.
200
+ * Returns the path if it exists, null otherwise.
201
+ */
202
+ async function findGlobalRulerDir() {
203
+ const globalConfigDir = path.join(getXdgConfigDir(), 'ruler');
204
+ try {
205
+ const stat = await fs_1.promises.stat(globalConfigDir);
206
+ if (stat.isDirectory()) {
207
+ return globalConfigDir;
208
+ }
209
+ }
210
+ catch {
211
+ // ignore if global config doesn't exist
212
+ }
213
+ return null;
214
+ }
215
+ /**
216
+ * Searches the entire directory tree from startPath to find all .ruler directories.
217
+ * Returns an array of .ruler directory paths from most specific to least specific.
218
+ */
219
+ async function findAllRulerDirs(startPath) {
220
+ const rulerDirs = [];
221
+ // Search the entire directory tree downwards from startPath
222
+ async function findRulerDirs(dir) {
223
+ try {
224
+ const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
225
+ for (const entry of entries) {
226
+ const fullPath = path.join(dir, entry.name);
227
+ if (entry.isDirectory()) {
228
+ if (entry.name === '.ruler') {
229
+ rulerDirs.push(fullPath);
230
+ }
231
+ else {
232
+ // Recursively search subdirectories (but skip hidden directories like .git)
233
+ if (!entry.name.startsWith('.')) {
234
+ await findRulerDirs(fullPath);
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ catch {
241
+ // ignore errors when reading directories
242
+ }
243
+ }
244
+ // Start searching from the startPath
245
+ await findRulerDirs(startPath);
246
+ // Sort by depth (most specific first) - deeper paths come first
247
+ rulerDirs.sort((a, b) => {
248
+ const depthA = a.split(path.sep).length;
249
+ const depthB = b.split(path.sep).length;
250
+ if (depthA !== depthB) {
251
+ return depthB - depthA; // Deeper paths first
252
+ }
253
+ return a.localeCompare(b); // Alphabetical for same depth
254
+ });
255
+ return rulerDirs;
256
+ }
@@ -79,11 +79,18 @@ async function loadUnifiedConfig(options) {
79
79
  Array.isArray(tomlRaw.default_agents)) {
80
80
  defaultAgents = tomlRaw.default_agents.map((a) => String(a));
81
81
  }
82
+ let nested = false;
83
+ if (tomlRaw &&
84
+ typeof tomlRaw === 'object' &&
85
+ typeof tomlRaw.nested === 'boolean') {
86
+ nested = tomlRaw.nested;
87
+ }
82
88
  const toml = {
83
89
  raw: tomlRaw,
84
90
  schemaVersion: 1,
85
91
  agents: {},
86
92
  defaultAgents,
93
+ nested,
87
94
  };
88
95
  // Collect rule markdown files
89
96
  let ruleFiles = [];
@@ -33,8 +33,11 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.loadRulerConfiguration = loadRulerConfiguration;
36
+ exports.loadNestedConfigurations = loadNestedConfigurations;
37
+ exports.loadSingleConfiguration = loadSingleConfiguration;
37
38
  exports.selectAgentsToRun = selectAgentsToRun;
39
+ exports.processHierarchicalConfigurations = processHierarchicalConfigurations;
40
+ exports.processSingleConfiguration = processSingleConfiguration;
38
41
  exports.applyConfigurationsToAgents = applyConfigurationsToAgents;
39
42
  exports.updateGitignore = updateGitignore;
40
43
  const path = __importStar(require("path"));
@@ -49,27 +52,99 @@ const propagateOpenCodeMcp_1 = require("../mcp/propagateOpenCodeMcp");
49
52
  const agent_utils_1 = require("../agents/agent-utils");
50
53
  const capabilities_1 = require("../mcp/capabilities");
51
54
  const constants_1 = require("../constants");
55
+ async function loadNestedConfigurations(projectRoot, configPath, localOnly) {
56
+ const { dirs: rulerDirs } = await findRulerDirectories(projectRoot, localOnly, true);
57
+ const rootConfig = await (0, ConfigLoader_1.loadConfig)({
58
+ projectRoot,
59
+ configPath,
60
+ });
61
+ const results = [];
62
+ const rulerDirConfigs = await processIndependentRulerDirs(rulerDirs);
63
+ for (const { rulerDir, files } of rulerDirConfigs) {
64
+ results.push(await createHierarchicalConfiguration(rulerDir, files, rootConfig));
65
+ }
66
+ return results;
67
+ }
52
68
  /**
53
- * Loads all necessary configurations for ruler operation.
54
- * @param projectRoot Root directory of the project
55
- * @param configPath Optional custom config path
56
- * @param localOnly Whether to search only locally for .ruler directory
57
- * @returns Promise resolving to the loaded configuration
69
+ * Processes each .ruler directory independently, returning configuration for each.
70
+ * Each .ruler directory gets its own rules (not merged with others).
58
71
  */
59
- async function loadRulerConfiguration(projectRoot, configPath, localOnly) {
60
- // Find the .ruler directory
61
- const rulerDir = await FileSystemUtils.findRulerDir(projectRoot, !localOnly);
62
- if (!rulerDir) {
63
- throw (0, constants_1.createRulerError)(`.ruler directory not found`, `Searched from: ${projectRoot}`);
72
+ async function processIndependentRulerDirs(rulerDirs) {
73
+ const results = [];
74
+ // Process each .ruler directory independently
75
+ for (const rulerDir of rulerDirs) {
76
+ const files = await FileSystemUtils.readMarkdownFiles(rulerDir);
77
+ results.push({ rulerDir, files });
64
78
  }
79
+ return results;
80
+ }
81
+ async function createHierarchicalConfiguration(rulerDir, files, rootConfig) {
82
+ await warnAboutLegacyMcpJson(rulerDir);
83
+ const concatenatedRules = (0, RuleProcessor_1.concatenateRules)(files, path.dirname(rulerDir));
84
+ return {
85
+ rulerDir,
86
+ config: rootConfig,
87
+ concatenatedRules,
88
+ rulerMcpJson: null, // No nested MCP support - each level uses root config only
89
+ };
90
+ }
91
+ /**
92
+ * Finds ruler directories based on the specified mode.
93
+ */
94
+ async function findRulerDirectories(projectRoot, localOnly, hierarchical) {
95
+ if (hierarchical) {
96
+ const dirs = await FileSystemUtils.findAllRulerDirs(projectRoot);
97
+ const allDirs = [...dirs];
98
+ // Add global config if not local-only
99
+ if (!localOnly) {
100
+ const globalDir = await FileSystemUtils.findGlobalRulerDir();
101
+ if (globalDir) {
102
+ allDirs.push(globalDir);
103
+ }
104
+ }
105
+ if (allDirs.length === 0) {
106
+ throw (0, constants_1.createRulerError)(`.ruler directory not found`, `Searched from: ${projectRoot}`);
107
+ }
108
+ return { dirs: allDirs, primaryDir: allDirs[0] };
109
+ }
110
+ else {
111
+ const dir = await FileSystemUtils.findRulerDir(projectRoot, !localOnly);
112
+ if (!dir) {
113
+ throw (0, constants_1.createRulerError)(`.ruler directory not found`, `Searched from: ${projectRoot}`);
114
+ }
115
+ return { dirs: [dir], primaryDir: dir };
116
+ }
117
+ }
118
+ /**
119
+ * Warns about legacy mcp.json files if they exist.
120
+ */
121
+ async function warnAboutLegacyMcpJson(rulerDir) {
122
+ try {
123
+ const legacyMcpPath = path.join(rulerDir, 'mcp.json');
124
+ await (await Promise.resolve().then(() => __importStar(require('fs/promises')))).access(legacyMcpPath);
125
+ console.warn('[ruler] Warning: Using legacy .ruler/mcp.json. Please migrate to ruler.toml. This fallback will be removed in a future release.');
126
+ }
127
+ catch {
128
+ // ignore
129
+ }
130
+ }
131
+ /**
132
+ * Loads configuration for single-directory mode (existing behavior).
133
+ */
134
+ async function loadSingleConfiguration(projectRoot, configPath, localOnly) {
135
+ // Find the single ruler directory
136
+ const { dirs: rulerDirs, primaryDir } = await findRulerDirectories(projectRoot, localOnly, false);
137
+ // Warn about legacy mcp.json
138
+ await warnAboutLegacyMcpJson(primaryDir);
65
139
  // Load the ruler.toml configuration
66
140
  const config = await (0, ConfigLoader_1.loadConfig)({
67
141
  projectRoot,
68
142
  configPath,
69
143
  });
70
- // Read and concatenate the markdown rule files
71
- const files = await FileSystemUtils.readMarkdownFiles(rulerDir);
72
- const concatenatedRules = (0, RuleProcessor_1.concatenateRules)(files, path.dirname(rulerDir));
144
+ // Read rule files
145
+ const files = await FileSystemUtils.readMarkdownFiles(rulerDirs[0]);
146
+ // Concatenate rules
147
+ const concatenatedRules = (0, RuleProcessor_1.concatenateRules)(files, path.dirname(primaryDir));
73
148
  // Load unified config to get merged MCP configuration
74
149
  const { loadUnifiedConfig } = await Promise.resolve().then(() => __importStar(require('./UnifiedConfigLoader')));
75
150
  const unifiedConfig = await loadUnifiedConfig({ projectRoot, configPath });
@@ -133,7 +208,43 @@ function selectAgentsToRun(allAgents, config) {
133
208
  return selected;
134
209
  }
135
210
  /**
136
- * Applies configurations to the selected agents.
211
+ * Processes hierarchical configurations by applying rules to each .ruler directory independently.
212
+ * Each directory gets its own set of rules and generates its own agent files.
213
+ * @param agents Array of agents to process
214
+ * @param configurations Array of hierarchical configurations for each .ruler directory
215
+ * @param verbose Whether to enable verbose logging
216
+ * @param dryRun Whether to perform a dry run
217
+ * @param cliMcpEnabled Whether MCP is enabled via CLI
218
+ * @param cliMcpStrategy MCP strategy from CLI
219
+ * @returns Promise resolving to array of generated file paths
220
+ */
221
+ async function processHierarchicalConfigurations(agents, configurations, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup = true) {
222
+ const allGeneratedPaths = [];
223
+ for (const config of configurations) {
224
+ console.log(`[ruler] Processing .ruler directory: ${config.rulerDir}`);
225
+ const rulerRoot = path.dirname(config.rulerDir);
226
+ const paths = await applyConfigurationsToAgents(agents, config.concatenatedRules, config.rulerMcpJson, config.config, rulerRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
227
+ allGeneratedPaths.push(...paths);
228
+ }
229
+ return allGeneratedPaths;
230
+ }
231
+ /**
232
+ * Processes a single configuration by applying rules to all selected agents.
233
+ * All rules are concatenated and applied to generate agent files in the project root.
234
+ * @param agents Array of agents to process
235
+ * @param configuration Single ruler configuration with concatenated rules
236
+ * @param projectRoot Root directory of the project
237
+ * @param verbose Whether to enable verbose logging
238
+ * @param dryRun Whether to perform a dry run
239
+ * @param cliMcpEnabled Whether MCP is enabled via CLI
240
+ * @param cliMcpStrategy MCP strategy from CLI
241
+ * @returns Promise resolving to array of generated file paths
242
+ */
243
+ async function processSingleConfiguration(agents, configuration, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup = true) {
244
+ return await applyConfigurationsToAgents(agents, configuration.concatenatedRules, configuration.rulerMcpJson, configuration.config, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
245
+ }
246
+ /**
247
+ * Applies configurations to the selected agents (internal function).
137
248
  * @param agents Array of agents to process
138
249
  * @param concatenatedRules Concatenated rule content
139
250
  * @param rulerMcpJson MCP configuration JSON
@@ -143,7 +254,7 @@ function selectAgentsToRun(allAgents, config) {
143
254
  * @param dryRun Whether to perform a dry run
144
255
  * @returns Promise resolving to array of generated file paths
145
256
  */
146
- async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJson, config, projectRoot, verbose, dryRun, cliMcpEnabled = true, cliMcpStrategy) {
257
+ async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJson, config, projectRoot, verbose, dryRun, cliMcpEnabled = true, cliMcpStrategy, backup = true) {
147
258
  const generatedPaths = [];
148
259
  let agentsMdWritten = false;
149
260
  for (const agent of agents) {
@@ -155,9 +266,11 @@ async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJs
155
266
  const outputPaths = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
156
267
  (0, constants_1.logVerbose)(`Agent ${agent.getName()} output paths: ${outputPaths.join(', ')}`, verbose);
157
268
  generatedPaths.push(...outputPaths);
158
- // Also add the backup file paths to the gitignore list
159
- const backupPaths = outputPaths.map((p) => `${p}.bak`);
160
- generatedPaths.push(...backupPaths);
269
+ // Only add the backup file paths to the gitignore list if backups are enabled
270
+ if (backup) {
271
+ const backupPaths = outputPaths.map((p) => `${p}.bak`);
272
+ generatedPaths.push(...backupPaths);
273
+ }
161
274
  if (dryRun) {
162
275
  (0, constants_1.logVerbose)(`DRY RUN: Would write rules to: ${outputPaths.join(', ')}`, verbose);
163
276
  }
@@ -188,80 +301,89 @@ async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJs
188
301
  };
189
302
  }
190
303
  if (!skipApplyForThisAgent) {
191
- await agent.applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, finalAgentConfig);
304
+ await agent.applyRulerConfig(concatenatedRules, projectRoot, rulerMcpJson, finalAgentConfig, backup);
192
305
  }
193
306
  }
194
307
  // Handle MCP configuration
195
- await handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson, projectRoot, generatedPaths, verbose, dryRun, cliMcpEnabled, cliMcpStrategy);
308
+ await handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson, projectRoot, generatedPaths, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
196
309
  }
197
310
  return generatedPaths;
198
311
  }
199
- /**
200
- * Handles MCP configuration for a specific agent.
201
- */
202
- async function handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson, projectRoot, generatedPaths, verbose, dryRun, cliMcpEnabled = true, cliMcpStrategy) {
203
- // Check if agent supports MCP at all
312
+ async function handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson, projectRoot, generatedPaths, verbose, dryRun, cliMcpEnabled = true, cliMcpStrategy, backup = true) {
204
313
  if (!(0, capabilities_1.agentSupportsMcp)(agent)) {
205
314
  (0, constants_1.logVerbose)(`Agent ${agent.getName()} does not support MCP - skipping MCP configuration`, verbose);
206
315
  return;
207
316
  }
208
317
  const dest = await (0, mcp_1.getNativeMcpPath)(agent.getName(), projectRoot);
209
318
  const mcpEnabledForAgent = cliMcpEnabled && (agentConfig?.mcp?.enabled ?? config.mcp?.enabled ?? true);
210
- if (dest && mcpEnabledForAgent && rulerMcpJson) {
211
- // Filter MCP configuration based on agent capabilities
212
- const filteredMcpJson = (0, capabilities_1.filterMcpConfigForAgent)(rulerMcpJson, agent);
213
- if (!filteredMcpJson) {
214
- (0, constants_1.logVerbose)(`No compatible MCP servers found for ${agent.getName()} - skipping MCP configuration`, verbose);
215
- return;
216
- }
217
- // Include MCP config file in .gitignore only if it's within the project directory
218
- if (dest.startsWith(projectRoot)) {
219
- const relativeDest = path.relative(projectRoot, dest);
220
- generatedPaths.push(relativeDest);
221
- // Also add the backup for the MCP file
319
+ if (!dest || !mcpEnabledForAgent || !rulerMcpJson) {
320
+ return;
321
+ }
322
+ const filteredMcpJson = (0, capabilities_1.filterMcpConfigForAgent)(rulerMcpJson, agent);
323
+ if (!filteredMcpJson) {
324
+ (0, constants_1.logVerbose)(`No compatible MCP servers found for ${agent.getName()} - skipping MCP configuration`, verbose);
325
+ return;
326
+ }
327
+ await updateGitignoreForMcpFile(dest, projectRoot, generatedPaths, backup);
328
+ await applyMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, projectRoot, cliMcpStrategy, dryRun, verbose, backup);
329
+ }
330
+ async function updateGitignoreForMcpFile(dest, projectRoot, generatedPaths, backup = true) {
331
+ if (dest.startsWith(projectRoot)) {
332
+ const relativeDest = path.relative(projectRoot, dest);
333
+ generatedPaths.push(relativeDest);
334
+ if (backup) {
222
335
  generatedPaths.push(`${relativeDest}.bak`);
223
336
  }
224
- // Prevent writing MCP configs outside the project root (e.g., legacy home-directory targets)
225
- if (!dest.startsWith(projectRoot)) {
226
- (0, constants_1.logVerbose)(`Skipping MCP config for ${agent.getName()} because target path is outside project: ${dest}`, verbose);
227
- return;
228
- }
229
- if (agent.getIdentifier() === 'openhands') {
230
- // *** Special handling for Open Hands ***
231
- if (dryRun) {
232
- (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating TOML file: ${dest}`, verbose);
233
- }
234
- else {
235
- await (0, propagateOpenHandsMcp_1.propagateMcpToOpenHands)(filteredMcpJson, dest);
236
- }
237
- }
238
- else if (agent.getIdentifier() === 'opencode') {
239
- // *** Special handling for OpenCode ***
240
- if (dryRun) {
241
- (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating OpenCode config file: ${dest}`, verbose);
242
- }
243
- else {
244
- await (0, propagateOpenCodeMcp_1.propagateMcpToOpenCode)(filteredMcpJson, dest);
245
- }
246
- }
247
- else {
248
- // Standard MCP handling using capabilities
249
- const strategy = cliMcpStrategy ??
250
- agentConfig?.mcp?.strategy ??
251
- config.mcp?.strategy ??
252
- 'merge';
253
- // Determine the correct server key for the agent
254
- const serverKey = agent.getMcpServerKey?.() || 'mcpServers';
255
- (0, constants_1.logVerbose)(`Applying filtered MCP config for ${agent.getName()} with strategy: ${strategy} and key: ${serverKey}`, verbose);
256
- if (dryRun) {
257
- (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config to: ${dest}`, verbose);
258
- }
259
- else {
260
- const existing = await (0, mcp_1.readNativeMcp)(dest);
261
- const merged = (0, merge_1.mergeMcp)(existing, filteredMcpJson, strategy, serverKey);
262
- await (0, mcp_1.writeNativeMcp)(dest, merged);
263
- }
337
+ }
338
+ }
339
+ async function applyMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, projectRoot, cliMcpStrategy, dryRun, verbose, backup = true) {
340
+ // Prevent writing MCP configs outside the project root (e.g., legacy home-directory targets)
341
+ if (!dest.startsWith(projectRoot)) {
342
+ (0, constants_1.logVerbose)(`Skipping MCP config for ${agent.getName()} because target path is outside project: ${dest}`, verbose);
343
+ return;
344
+ }
345
+ if (agent.getIdentifier() === 'openhands') {
346
+ return await applyOpenHandsMcpConfiguration(filteredMcpJson, dest, dryRun, verbose, backup);
347
+ }
348
+ if (agent.getIdentifier() === 'opencode') {
349
+ return await applyOpenCodeMcpConfiguration(filteredMcpJson, dest, dryRun, verbose, backup);
350
+ }
351
+ return await applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, cliMcpStrategy, dryRun, verbose, backup);
352
+ }
353
+ async function applyOpenHandsMcpConfiguration(filteredMcpJson, dest, dryRun, verbose, backup = true) {
354
+ if (dryRun) {
355
+ (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating TOML file: ${dest}`, verbose);
356
+ }
357
+ else {
358
+ await (0, propagateOpenHandsMcp_1.propagateMcpToOpenHands)(filteredMcpJson, dest, backup);
359
+ }
360
+ }
361
+ async function applyOpenCodeMcpConfiguration(filteredMcpJson, dest, dryRun, verbose, backup = true) {
362
+ if (dryRun) {
363
+ (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating OpenCode config file: ${dest}`, verbose);
364
+ }
365
+ else {
366
+ await (0, propagateOpenCodeMcp_1.propagateMcpToOpenCode)(filteredMcpJson, dest, backup);
367
+ }
368
+ }
369
+ async function applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, cliMcpStrategy, dryRun, verbose, backup = true) {
370
+ const strategy = cliMcpStrategy ??
371
+ agentConfig?.mcp?.strategy ??
372
+ config.mcp?.strategy ??
373
+ 'merge';
374
+ const serverKey = agent.getMcpServerKey?.() ?? 'mcpServers';
375
+ (0, constants_1.logVerbose)(`Applying filtered MCP config for ${agent.getName()} with strategy: ${strategy} and key: ${serverKey}`, verbose);
376
+ if (dryRun) {
377
+ (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config to: ${dest}`, verbose);
378
+ }
379
+ else {
380
+ if (backup) {
381
+ const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
382
+ await backupFile(dest);
264
383
  }
384
+ const existing = await (0, mcp_1.readNativeMcp)(dest);
385
+ const merged = (0, merge_1.mergeMcp)(existing, filteredMcpJson, strategy, serverKey);
386
+ await (0, mcp_1.writeNativeMcp)(dest, merged);
265
387
  }
266
388
  }
267
389
  /**
@@ -272,7 +394,7 @@ async function handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson,
272
394
  * @param cliGitignoreEnabled CLI gitignore setting
273
395
  * @param dryRun Whether to perform a dry run
274
396
  */
275
- async function updateGitignore(projectRoot, generatedPaths, config, cliGitignoreEnabled, dryRun) {
397
+ async function updateGitignore(projectRoot, generatedPaths, config, cliGitignoreEnabled, dryRun, backup = true) {
276
398
  // Configuration precedence: CLI > TOML > Default (enabled)
277
399
  let gitignoreEnabled;
278
400
  if (cliGitignoreEnabled !== undefined) {
@@ -286,8 +408,10 @@ async function updateGitignore(projectRoot, generatedPaths, config, cliGitignore
286
408
  }
287
409
  if (gitignoreEnabled && generatedPaths.length > 0) {
288
410
  const uniquePaths = [...new Set(generatedPaths)];
289
- // Add wildcard pattern for backup files
290
- uniquePaths.push('*.bak');
411
+ // Add wildcard pattern for backup files only if backup is enabled
412
+ if (backup) {
413
+ uniquePaths.push('*.bak');
414
+ }
291
415
  if (uniquePaths.length > 0) {
292
416
  const prefix = (0, constants_1.actionPrefix)(dryRun);
293
417
  if (dryRun) {
package/dist/lib.js CHANGED
@@ -17,24 +17,51 @@ const agents = agents_1.allAgents;
17
17
  * @param projectRoot Root directory of the project
18
18
  * @param includedAgents Optional list of agent name filters (case-insensitive substrings)
19
19
  */
20
- async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cliMcpEnabled = true, cliMcpStrategy, cliGitignoreEnabled, verbose = false, dryRun = false, localOnly = false) {
20
+ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cliMcpEnabled = true, cliMcpStrategy, cliGitignoreEnabled, verbose = false, dryRun = false, localOnly = false, nested = false, backup = true) {
21
21
  // Load configuration and rules
22
22
  (0, constants_1.logVerbose)(`Loading configuration from project root: ${projectRoot}`, verbose);
23
23
  if (configPath) {
24
24
  (0, constants_1.logVerbose)(`Using custom config path: ${configPath}`, verbose);
25
25
  }
26
- const rulerConfiguration = await (0, apply_engine_1.loadRulerConfiguration)(projectRoot, configPath, localOnly);
27
- // Add CLI agents to the configuration
28
- rulerConfiguration.config.cliAgents = includedAgents;
29
- (0, constants_1.logVerbose)(`Loaded configuration with ${Object.keys(rulerConfiguration.config.agentConfigs).length} agent configs`, verbose);
30
- (0, constants_1.logVerbose)(`Found .ruler directory with ${rulerConfiguration.concatenatedRules.length} characters of rules`, verbose);
26
+ let selectedAgents;
27
+ let generatedPaths;
28
+ let loadedConfig;
29
+ if (nested) {
30
+ const hierarchicalConfigs = await (0, apply_engine_1.loadNestedConfigurations)(projectRoot, configPath, localOnly);
31
+ if (hierarchicalConfigs.length === 0) {
32
+ throw new Error('No .ruler directories found');
33
+ }
34
+ // Use the root config for agent selection (all levels share the same agent settings)
35
+ const rootConfig = hierarchicalConfigs[0].config;
36
+ loadedConfig = rootConfig;
37
+ rootConfig.cliAgents = includedAgents;
38
+ (0, constants_1.logVerbose)(`Loaded ${hierarchicalConfigs.length} .ruler directory configurations`, verbose);
39
+ (0, constants_1.logVerbose)(`Root configuration has ${Object.keys(rootConfig.agentConfigs).length} agent configs`, verbose);
40
+ normalizeAgentConfigs(rootConfig, agents);
41
+ selectedAgents = (0, apply_engine_1.selectAgentsToRun)(agents, rootConfig);
42
+ (0, constants_1.logVerbose)(`Selected ${selectedAgents.length} agents: ${selectedAgents.map((a) => a.getName()).join(', ')}`, verbose);
43
+ generatedPaths = await (0, apply_engine_1.processHierarchicalConfigurations)(selectedAgents, hierarchicalConfigs, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
44
+ }
45
+ else {
46
+ const singleConfig = await (0, apply_engine_1.loadSingleConfiguration)(projectRoot, configPath, localOnly);
47
+ loadedConfig = singleConfig.config;
48
+ singleConfig.config.cliAgents = includedAgents;
49
+ (0, constants_1.logVerbose)(`Loaded configuration with ${Object.keys(singleConfig.config.agentConfigs).length} agent configs`, verbose);
50
+ (0, constants_1.logVerbose)(`Found .ruler directory with ${singleConfig.concatenatedRules.length} characters of rules`, verbose);
51
+ normalizeAgentConfigs(singleConfig.config, agents);
52
+ selectedAgents = (0, apply_engine_1.selectAgentsToRun)(agents, singleConfig.config);
53
+ (0, constants_1.logVerbose)(`Selected ${selectedAgents.length} agents: ${selectedAgents.map((a) => a.getName()).join(', ')}`, verbose);
54
+ generatedPaths = await (0, apply_engine_1.processSingleConfiguration)(selectedAgents, singleConfig, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backup);
55
+ }
56
+ await (0, apply_engine_1.updateGitignore)(projectRoot, generatedPaths, loadedConfig, cliGitignoreEnabled, dryRun, backup);
57
+ }
58
+ /**
59
+ * Normalizes per-agent config keys to agent identifiers for consistent lookup.
60
+ * Maps both exact identifier matches and substring matches with agent names.
61
+ * @param config The configuration object to normalize
62
+ * @param agents Array of available agents
63
+ */
64
+ function normalizeAgentConfigs(config, agents) {
31
65
  // Normalize per-agent config keys to agent identifiers (exact match or substring match)
32
- rulerConfiguration.config.agentConfigs = (0, config_utils_1.mapRawAgentConfigs)(rulerConfiguration.config.agentConfigs, agents);
33
- // Select agents to run
34
- const selectedAgents = (0, apply_engine_1.selectAgentsToRun)(agents, rulerConfiguration.config);
35
- (0, constants_1.logVerbose)(`Selected ${selectedAgents.length} agents: ${selectedAgents.map((a) => a.getName()).join(', ')}`, verbose);
36
- // Apply configurations to agents
37
- const generatedPaths = await (0, apply_engine_1.applyConfigurationsToAgents)(selectedAgents, rulerConfiguration.concatenatedRules, rulerConfiguration.rulerMcpJson, rulerConfiguration.config, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy);
38
- // Update .gitignore
39
- await (0, apply_engine_1.updateGitignore)(projectRoot, generatedPaths, rulerConfiguration.config, cliGitignoreEnabled, dryRun);
66
+ config.agentConfigs = (0, config_utils_1.mapRawAgentConfigs)(config.agentConfigs, agents);
40
67
  }
@@ -85,7 +85,7 @@ function transformToOpenCodeFormat(rulerMcp) {
85
85
  mcp: openCodeServers,
86
86
  };
87
87
  }
88
- async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath) {
88
+ async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath, backup = true) {
89
89
  const rulerMcp = rulerMcpData || {};
90
90
  // Read existing OpenCode config if it exists
91
91
  let existingConfig = {};
@@ -108,5 +108,9 @@ async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath) {
108
108
  },
109
109
  };
110
110
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(openCodeConfigPath));
111
+ if (backup) {
112
+ const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
113
+ await backupFile(openCodeConfigPath);
114
+ }
111
115
  await fs.writeFile(openCodeConfigPath, JSON.stringify(finalConfig, null, 2) + '\n');
112
116
  }
@@ -90,7 +90,7 @@ function normalizeRemoteServerArray(entries) {
90
90
  // All entries are strings, keep as is
91
91
  return entries;
92
92
  }
93
- async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath) {
93
+ async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath, backup = true) {
94
94
  const rulerMcp = rulerMcpData || {};
95
95
  // Always use the legacy Ruler MCP config format as input (top-level "mcpServers" key)
96
96
  const rulerServers = rulerMcp.mcpServers || {};
@@ -162,5 +162,9 @@ async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath) {
162
162
  config.mcp.sse_servers = normalizeRemoteServerArray(Array.from(existingSseServers.values()));
163
163
  config.mcp.shttp_servers = normalizeRemoteServerArray(Array.from(existingShttpServers.values()));
164
164
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(openHandsConfigPath));
165
+ if (backup) {
166
+ const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
167
+ await backupFile(openHandsConfigPath);
168
+ }
165
169
  await fs.writeFile(openHandsConfigPath, (0, toml_1.stringify)(config));
166
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellectronica/ruler",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Ruler — apply the same rules to all coding agents",
5
5
  "main": "dist/lib.js",
6
6
  "scripts": {