@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 +107 -47
- package/dist/agents/AbstractAgent.js +4 -2
- package/dist/agents/AgentsMdAgent.js +9 -5
- package/dist/agents/AiderAgent.js +5 -3
- package/dist/agents/AugmentCodeAgent.js +4 -2
- package/dist/agents/CursorAgent.js +4 -2
- package/dist/agents/WindsurfAgent.js +14 -0
- package/dist/cli/commands.js +10 -0
- package/dist/cli/handlers.js +7 -1
- package/dist/core/ConfigLoader.js +3 -0
- package/dist/core/FileSystemUtils.js +74 -2
- package/dist/core/UnifiedConfigLoader.js +7 -0
- package/dist/core/apply-engine.js +205 -81
- package/dist/lib.js +41 -14
- package/dist/mcp/propagateOpenCodeMcp.js +5 -1
- package/dist/mcp/propagateOpenHandsMcp.js +5 -1
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -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) => {
|
package/dist/cli/handlers.js
CHANGED
|
@@ -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
|
-
//
|
|
151
|
-
|
|
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.
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
71
|
-
const files = await FileSystemUtils.readMarkdownFiles(
|
|
72
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
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
|
-
|
|
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
|
}
|