@intellectronica/ruler 0.3.42 → 0.3.44

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.
Files changed (53) hide show
  1. package/README.md +98 -11
  2. package/dist/agents/AbstractAgent.js +3 -2
  3. package/dist/agents/AgentsMdAgent.js +3 -2
  4. package/dist/agents/AiderAgent.js +4 -3
  5. package/dist/agents/AmazonQCliAgent.js +6 -4
  6. package/dist/agents/AugmentCodeAgent.js +3 -2
  7. package/dist/agents/CodexCliAgent.js +1 -1
  8. package/dist/agents/CopilotAgent.js +1 -1
  9. package/dist/agents/CrushAgent.d.ts +1 -1
  10. package/dist/agents/CrushAgent.js +15 -6
  11. package/dist/agents/FirebenderAgent.js +5 -4
  12. package/dist/agents/GeminiCliAgent.d.ts +1 -0
  13. package/dist/agents/GeminiCliAgent.js +11 -5
  14. package/dist/agents/IAgent.d.ts +2 -0
  15. package/dist/agents/MistralVibeAgent.js +14 -3
  16. package/dist/agents/OpenCodeAgent.d.ts +1 -1
  17. package/dist/agents/OpenCodeAgent.js +10 -3
  18. package/dist/agents/QwenCodeAgent.d.ts +1 -0
  19. package/dist/agents/QwenCodeAgent.js +9 -3
  20. package/dist/agents/RooCodeAgent.js +3 -2
  21. package/dist/agents/ZedAgent.js +3 -3
  22. package/dist/constants.d.ts +1 -1
  23. package/dist/constants.js +1 -1
  24. package/dist/core/ConfigLoader.d.ts +2 -0
  25. package/dist/core/ConfigLoader.js +87 -6
  26. package/dist/core/FileSystemUtils.d.ts +5 -2
  27. package/dist/core/FileSystemUtils.js +121 -3
  28. package/dist/core/GitignoreUtils.d.ts +10 -0
  29. package/dist/core/GitignoreUtils.js +62 -31
  30. package/dist/core/SkillsProcessor.d.ts +2 -2
  31. package/dist/core/SkillsProcessor.js +46 -37
  32. package/dist/core/SkillsUtils.js +4 -1
  33. package/dist/core/SubagentsProcessor.js +8 -5
  34. package/dist/core/UnifiedConfigLoader.js +96 -11
  35. package/dist/core/UnifiedConfigTypes.d.ts +3 -1
  36. package/dist/core/agent-selection.js +6 -4
  37. package/dist/core/apply-engine.d.ts +1 -0
  38. package/dist/core/apply-engine.js +38 -15
  39. package/dist/core/revert-engine.d.ts +2 -1
  40. package/dist/core/revert-engine.js +79 -27
  41. package/dist/lib.js +9 -6
  42. package/dist/mcp/capabilities.js +2 -2
  43. package/dist/mcp/merge.js +28 -26
  44. package/dist/mcp/propagateOpenCodeMcp.d.ts +1 -1
  45. package/dist/mcp/propagateOpenCodeMcp.js +10 -3
  46. package/dist/mcp/propagateOpenHandsMcp.d.ts +1 -1
  47. package/dist/mcp/propagateOpenHandsMcp.js +18 -7
  48. package/dist/paths/mcp.d.ts +1 -1
  49. package/dist/paths/mcp.js +12 -5
  50. package/dist/revert.js +29 -27
  51. package/dist/vscode/settings.d.ts +1 -1
  52. package/dist/vscode/settings.js +3 -3
  53. package/package.json +6 -4
@@ -54,6 +54,7 @@ const path = __importStar(require("path"));
54
54
  const fs = __importStar(require("fs/promises"));
55
55
  const constants_1 = require("../constants");
56
56
  const SkillsUtils_1 = require("./SkillsUtils");
57
+ const FileSystemUtils_1 = require("./FileSystemUtils");
57
58
  /**
58
59
  * Discovers skills in the project's .ruler/skills directory.
59
60
  * Returns discovered skills and any validation warnings.
@@ -102,7 +103,8 @@ async function getSkillsGitignorePaths(projectRoot, agents) {
102
103
  factory: FACTORY_SKILLS_PATH,
103
104
  antigravity: ANTIGRAVITY_SKILLS_PATH,
104
105
  };
105
- return Array.from(selectedTargets).map((target) => path.join(projectRoot, targetPaths[target]));
106
+ const pathSet = new Set(Array.from(selectedTargets).map((target) => path.join(projectRoot, targetPaths[target])));
107
+ return Array.from(pathSet);
106
108
  }
107
109
  /**
108
110
  * Module-level state to track if experimental warning has been shown.
@@ -137,7 +139,7 @@ const SKILL_TARGET_TO_IDENTIFIERS = new Map([
137
139
  ['factory', ['factory']],
138
140
  ['antigravity', ['antigravity']],
139
141
  ]);
140
- const SKILL_TARGET_PATHS = [
142
+ const SKILL_TARGET_PATHS = Array.from(new Set([
141
143
  constants_1.CLAUDE_SKILLS_PATH,
142
144
  constants_1.CODEX_SKILLS_PATH,
143
145
  constants_1.OPENCODE_SKILLS_PATH,
@@ -151,7 +153,7 @@ const SKILL_TARGET_PATHS = [
151
153
  constants_1.WINDSURF_SKILLS_PATH,
152
154
  constants_1.FACTORY_SKILLS_PATH,
153
155
  constants_1.ANTIGRAVITY_SKILLS_PATH,
154
- ];
156
+ ]));
155
157
  function getSelectedSkillTargets(agents) {
156
158
  const selectedIdentifiers = new Set(agents
157
159
  .filter((agent) => agent.supportsNativeSkills?.())
@@ -185,10 +187,14 @@ async function cleanupSkillsDirectory(projectRoot, relativePath, dryRun, verbose
185
187
  (0, constants_1.logVerboseInfo)(`DRY RUN: Would remove ${relativePath}`, verbose, dryRun);
186
188
  return;
187
189
  }
190
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(targetPath, projectRoot, 'Refusing to remove skills directory through symlinked path');
188
191
  await fs.rm(targetPath, { recursive: true, force: true });
189
192
  (0, constants_1.logVerboseInfo)(`Removed ${relativePath} (skills disabled)`, verbose, dryRun);
190
193
  }
191
- async function createTempSkillsDir(parentDir) {
194
+ async function createTempSkillsDir(parentDir, containmentRoot) {
195
+ if (containmentRoot) {
196
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(parentDir, containmentRoot, 'Refusing to create temporary skills directory through symlinked path');
197
+ }
192
198
  return fs.mkdtemp(path.join(parentDir, 'skills.tmp-'));
193
199
  }
194
200
  const TRANSIENT_RENAME_ERROR_CODES = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']);
@@ -204,7 +210,10 @@ function isTransientRenameError(error) {
204
210
  function wait(ms) {
205
211
  return new Promise((resolve) => setTimeout(resolve, ms));
206
212
  }
207
- async function replaceSkillsDirectory(tempDir, targetDir, fsOps = fs) {
213
+ async function replaceSkillsDirectory(tempDir, targetDir, fsOps = fs, containmentRoot) {
214
+ if (containmentRoot) {
215
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(targetDir, containmentRoot, 'Refusing to replace skills directory through symlinked path');
216
+ }
208
217
  let lastError;
209
218
  for (let attempt = 1; attempt <= RENAME_RETRY_ATTEMPTS; attempt += 1) {
210
219
  try {
@@ -362,7 +371,7 @@ async function propagateSkillsForClaude(projectRoot, options) {
362
371
  // Ensure .claude directory exists
363
372
  await fs.mkdir(claudeDir, { recursive: true });
364
373
  // Use atomic replace: copy to temp, then rename
365
- const tempDir = await createTempSkillsDir(claudeDir);
374
+ const tempDir = await createTempSkillsDir(claudeDir, projectRoot);
366
375
  try {
367
376
  // Copy to temp directory
368
377
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -375,7 +384,7 @@ async function propagateSkillsForClaude(projectRoot, options) {
375
384
  // Target didn't exist, that's fine
376
385
  }
377
386
  // Rename temp to target
378
- await replaceSkillsDirectory(tempDir, claudeSkillsPath);
387
+ await replaceSkillsDirectory(tempDir, claudeSkillsPath, fs, projectRoot);
379
388
  }
380
389
  catch (error) {
381
390
  // Clean up temp directory on error
@@ -390,14 +399,14 @@ async function propagateSkillsForClaude(projectRoot, options) {
390
399
  return [];
391
400
  }
392
401
  /**
393
- * Propagates skills for OpenAI Codex CLI by copying .ruler/skills to .codex/skills.
402
+ * Propagates skills for OpenAI Codex CLI by copying .ruler/skills to .agents/skills.
394
403
  * Uses atomic replace to ensure safe overwriting of existing skills.
395
404
  * Returns dry-run steps if dryRun is true, otherwise returns empty array.
396
405
  */
397
406
  async function propagateSkillsForCodex(projectRoot, options) {
398
407
  const skillsDir = path.join(projectRoot, constants_1.RULER_SKILLS_PATH);
399
- const codexSkillsPath = path.join(projectRoot, constants_1.CODEX_SKILLS_PATH);
400
- const codexDir = path.dirname(codexSkillsPath);
408
+ const agentsSkillsPath = path.join(projectRoot, constants_1.CODEX_SKILLS_PATH);
409
+ const agentsDir = path.dirname(agentsSkillsPath);
401
410
  // Check if source skills directory exists
402
411
  try {
403
412
  await fs.access(skillsDir);
@@ -409,23 +418,23 @@ async function propagateSkillsForCodex(projectRoot, options) {
409
418
  if (options.dryRun) {
410
419
  return [`Copy skills from ${constants_1.RULER_SKILLS_PATH} to ${constants_1.CODEX_SKILLS_PATH}`];
411
420
  }
412
- // Ensure .codex directory exists
413
- await fs.mkdir(codexDir, { recursive: true });
421
+ // Ensure .agents directory exists
422
+ await fs.mkdir(agentsDir, { recursive: true });
414
423
  // Use atomic replace: copy to temp, then rename
415
- const tempDir = await createTempSkillsDir(codexDir);
424
+ const tempDir = await createTempSkillsDir(agentsDir, projectRoot);
416
425
  try {
417
426
  // Copy to temp directory
418
427
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
419
428
  // Atomically replace the target
420
429
  // First, remove existing target if it exists
421
430
  try {
422
- await fs.rm(codexSkillsPath, { recursive: true, force: true });
431
+ await fs.rm(agentsSkillsPath, { recursive: true, force: true });
423
432
  }
424
433
  catch {
425
434
  // Target didn't exist, that's fine
426
435
  }
427
436
  // Rename temp to target
428
- await replaceSkillsDirectory(tempDir, codexSkillsPath);
437
+ await replaceSkillsDirectory(tempDir, agentsSkillsPath, fs, projectRoot);
429
438
  }
430
439
  catch (error) {
431
440
  // Clean up temp directory on error
@@ -462,7 +471,7 @@ async function propagateSkillsForOpenCode(projectRoot, options) {
462
471
  // Ensure .opencode directory exists
463
472
  await fs.mkdir(opencodeDir, { recursive: true });
464
473
  // Use atomic replace: copy to temp, then rename
465
- const tempDir = await createTempSkillsDir(opencodeDir);
474
+ const tempDir = await createTempSkillsDir(opencodeDir, projectRoot);
466
475
  try {
467
476
  // Copy to temp directory
468
477
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -475,7 +484,7 @@ async function propagateSkillsForOpenCode(projectRoot, options) {
475
484
  // Target didn't exist, that's fine
476
485
  }
477
486
  // Rename temp to target
478
- await replaceSkillsDirectory(tempDir, opencodeSkillsPath);
487
+ await replaceSkillsDirectory(tempDir, opencodeSkillsPath, fs, projectRoot);
479
488
  }
480
489
  catch (error) {
481
490
  // Clean up temp directory on error
@@ -512,7 +521,7 @@ async function propagateSkillsForPi(projectRoot, options) {
512
521
  // Ensure .pi directory exists
513
522
  await fs.mkdir(piDir, { recursive: true });
514
523
  // Use atomic replace: copy to temp, then rename
515
- const tempDir = await createTempSkillsDir(piDir);
524
+ const tempDir = await createTempSkillsDir(piDir, projectRoot);
516
525
  try {
517
526
  // Copy to temp directory
518
527
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -525,7 +534,7 @@ async function propagateSkillsForPi(projectRoot, options) {
525
534
  // Target didn't exist, that's fine
526
535
  }
527
536
  // Rename temp to target
528
- await replaceSkillsDirectory(tempDir, piSkillsPath);
537
+ await replaceSkillsDirectory(tempDir, piSkillsPath, fs, projectRoot);
529
538
  }
530
539
  catch (error) {
531
540
  // Clean up temp directory on error
@@ -562,7 +571,7 @@ async function propagateSkillsForGoose(projectRoot, options) {
562
571
  // Ensure .agents directory exists
563
572
  await fs.mkdir(gooseDir, { recursive: true });
564
573
  // Use atomic replace: copy to temp, then rename
565
- const tempDir = await createTempSkillsDir(gooseDir);
574
+ const tempDir = await createTempSkillsDir(gooseDir, projectRoot);
566
575
  try {
567
576
  // Copy to temp directory
568
577
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -575,7 +584,7 @@ async function propagateSkillsForGoose(projectRoot, options) {
575
584
  // Target didn't exist, that's fine
576
585
  }
577
586
  // Rename temp to target
578
- await replaceSkillsDirectory(tempDir, gooseSkillsPath);
587
+ await replaceSkillsDirectory(tempDir, gooseSkillsPath, fs, projectRoot);
579
588
  }
580
589
  catch (error) {
581
590
  // Clean up temp directory on error
@@ -612,7 +621,7 @@ async function propagateSkillsForVibe(projectRoot, options) {
612
621
  // Ensure .vibe directory exists
613
622
  await fs.mkdir(vibeDir, { recursive: true });
614
623
  // Use atomic replace: copy to temp, then rename
615
- const tempDir = await createTempSkillsDir(vibeDir);
624
+ const tempDir = await createTempSkillsDir(vibeDir, projectRoot);
616
625
  try {
617
626
  // Copy to temp directory
618
627
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -625,7 +634,7 @@ async function propagateSkillsForVibe(projectRoot, options) {
625
634
  // Target didn't exist, that's fine
626
635
  }
627
636
  // Rename temp to target
628
- await replaceSkillsDirectory(tempDir, vibeSkillsPath);
637
+ await replaceSkillsDirectory(tempDir, vibeSkillsPath, fs, projectRoot);
629
638
  }
630
639
  catch (error) {
631
640
  // Clean up temp directory on error
@@ -662,7 +671,7 @@ async function propagateSkillsForRoo(projectRoot, options) {
662
671
  // Ensure .roo directory exists
663
672
  await fs.mkdir(rooDir, { recursive: true });
664
673
  // Use atomic replace: copy to temp, then rename
665
- const tempDir = await createTempSkillsDir(rooDir);
674
+ const tempDir = await createTempSkillsDir(rooDir, projectRoot);
666
675
  try {
667
676
  // Copy to temp directory
668
677
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -675,7 +684,7 @@ async function propagateSkillsForRoo(projectRoot, options) {
675
684
  // Target didn't exist, that's fine
676
685
  }
677
686
  // Rename temp to target
678
- await replaceSkillsDirectory(tempDir, rooSkillsPath);
687
+ await replaceSkillsDirectory(tempDir, rooSkillsPath, fs, projectRoot);
679
688
  }
680
689
  catch (error) {
681
690
  // Clean up temp directory on error
@@ -712,7 +721,7 @@ async function propagateSkillsForGemini(projectRoot, options) {
712
721
  // Ensure .gemini directory exists
713
722
  await fs.mkdir(geminiDir, { recursive: true });
714
723
  // Use atomic replace: copy to temp, then rename
715
- const tempDir = await createTempSkillsDir(geminiDir);
724
+ const tempDir = await createTempSkillsDir(geminiDir, projectRoot);
716
725
  try {
717
726
  // Copy to temp directory
718
727
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -725,7 +734,7 @@ async function propagateSkillsForGemini(projectRoot, options) {
725
734
  // Target didn't exist, that's fine
726
735
  }
727
736
  // Rename temp to target
728
- await replaceSkillsDirectory(tempDir, geminiSkillsPath);
737
+ await replaceSkillsDirectory(tempDir, geminiSkillsPath, fs, projectRoot);
729
738
  }
730
739
  catch (error) {
731
740
  // Clean up temp directory on error
@@ -758,7 +767,7 @@ async function propagateSkillsForJunie(projectRoot, options) {
758
767
  return [`Copy skills from ${constants_1.RULER_SKILLS_PATH} to ${constants_1.JUNIE_SKILLS_PATH}`];
759
768
  }
760
769
  await fs.mkdir(junieDir, { recursive: true });
761
- const tempDir = await createTempSkillsDir(junieDir);
770
+ const tempDir = await createTempSkillsDir(junieDir, projectRoot);
762
771
  try {
763
772
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
764
773
  try {
@@ -767,7 +776,7 @@ async function propagateSkillsForJunie(projectRoot, options) {
767
776
  catch {
768
777
  // Target didn't exist, that's fine
769
778
  }
770
- await replaceSkillsDirectory(tempDir, junieSkillsPath);
779
+ await replaceSkillsDirectory(tempDir, junieSkillsPath, fs, projectRoot);
771
780
  }
772
781
  catch (error) {
773
782
  try {
@@ -803,7 +812,7 @@ async function propagateSkillsForCursor(projectRoot, options) {
803
812
  // Ensure .cursor directory exists
804
813
  await fs.mkdir(cursorDir, { recursive: true });
805
814
  // Use atomic replace: copy to temp, then rename
806
- const tempDir = await createTempSkillsDir(cursorDir);
815
+ const tempDir = await createTempSkillsDir(cursorDir, projectRoot);
807
816
  try {
808
817
  // Copy to temp directory
809
818
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -816,7 +825,7 @@ async function propagateSkillsForCursor(projectRoot, options) {
816
825
  // Target didn't exist, that's fine
817
826
  }
818
827
  // Rename temp to target
819
- await replaceSkillsDirectory(tempDir, cursorSkillsPath);
828
+ await replaceSkillsDirectory(tempDir, cursorSkillsPath, fs, projectRoot);
820
829
  }
821
830
  catch (error) {
822
831
  // Clean up temp directory on error
@@ -853,7 +862,7 @@ async function propagateSkillsForWindsurf(projectRoot, options) {
853
862
  // Ensure .windsurf directory exists
854
863
  await fs.mkdir(windsurfDir, { recursive: true });
855
864
  // Use atomic replace: copy to temp, then rename
856
- const tempDir = await createTempSkillsDir(windsurfDir);
865
+ const tempDir = await createTempSkillsDir(windsurfDir, projectRoot);
857
866
  try {
858
867
  // Copy to temp directory
859
868
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -866,7 +875,7 @@ async function propagateSkillsForWindsurf(projectRoot, options) {
866
875
  // Target didn't exist, that's fine
867
876
  }
868
877
  // Rename temp to target
869
- await replaceSkillsDirectory(tempDir, windsurfSkillsPath);
878
+ await replaceSkillsDirectory(tempDir, windsurfSkillsPath, fs, projectRoot);
870
879
  }
871
880
  catch (error) {
872
881
  // Clean up temp directory on error
@@ -903,7 +912,7 @@ async function propagateSkillsForFactory(projectRoot, options) {
903
912
  // Ensure .factory directory exists
904
913
  await fs.mkdir(factoryDir, { recursive: true });
905
914
  // Use atomic replace: copy to temp, then rename
906
- const tempDir = await createTempSkillsDir(factoryDir);
915
+ const tempDir = await createTempSkillsDir(factoryDir, projectRoot);
907
916
  try {
908
917
  // Copy to temp directory
909
918
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -916,7 +925,7 @@ async function propagateSkillsForFactory(projectRoot, options) {
916
925
  // Target didn't exist, that's fine
917
926
  }
918
927
  // Rename temp to target
919
- await replaceSkillsDirectory(tempDir, factorySkillsPath);
928
+ await replaceSkillsDirectory(tempDir, factorySkillsPath, fs, projectRoot);
920
929
  }
921
930
  catch (error) {
922
931
  // Clean up temp directory on error
@@ -955,7 +964,7 @@ async function propagateSkillsForAntigravity(projectRoot, options) {
955
964
  // Ensure .agent directory exists
956
965
  await fs.mkdir(antigravityDir, { recursive: true });
957
966
  // Use atomic replace: copy to temp, then rename
958
- const tempDir = await createTempSkillsDir(antigravityDir);
967
+ const tempDir = await createTempSkillsDir(antigravityDir, projectRoot);
959
968
  try {
960
969
  // Copy to temp directory
961
970
  await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
@@ -968,7 +977,7 @@ async function propagateSkillsForAntigravity(projectRoot, options) {
968
977
  // Target didn't exist, that's fine
969
978
  }
970
979
  // Rename temp to target
971
- await replaceSkillsDirectory(tempDir, antigravitySkillsPath);
980
+ await replaceSkillsDirectory(tempDir, antigravitySkillsPath, fs, projectRoot);
972
981
  }
973
982
  catch (error) {
974
983
  // Clean up temp directory on error
@@ -137,7 +137,10 @@ function formatValidationWarnings(warnings) {
137
137
  * Recursively copies a directory and all its contents.
138
138
  */
139
139
  async function copyRecursive(src, dest) {
140
- const stat = await fs.stat(src);
140
+ const stat = await fs.lstat(src);
141
+ if (stat.isSymbolicLink()) {
142
+ return;
143
+ }
141
144
  if (stat.isDirectory()) {
142
145
  await fs.mkdir(dest, { recursive: true });
143
146
  const entries = await fs.readdir(src, { withFileTypes: true });
@@ -48,6 +48,7 @@ const yaml = __importStar(require("js-yaml"));
48
48
  const toml_1 = require("@iarna/toml");
49
49
  const constants_1 = require("../constants");
50
50
  const SubagentsUtils_1 = require("./SubagentsUtils");
51
+ const FileSystemUtils_1 = require("./FileSystemUtils");
51
52
  /**
52
53
  * Discovers subagent definitions in `.ruler/agents/`.
53
54
  * Each `.md` file is parsed for YAML frontmatter (name, description, …).
@@ -173,8 +174,9 @@ function ensureBodyFormatting(body) {
173
174
  * Stages files into a temp directory and atomically swaps it into place.
174
175
  * Mirrors the pattern used by SkillsProcessor for safe overwriting.
175
176
  */
176
- async function writeAgentsDirectoryAtomic(targetDir, files) {
177
+ async function writeAgentsDirectoryAtomic(targetDir, files, projectRoot) {
177
178
  const parent = path.dirname(targetDir);
179
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(targetDir, projectRoot, 'Refusing to replace subagents directory through symlinked path');
178
180
  await fs.mkdir(parent, { recursive: true });
179
181
  const tempDir = path.join(parent, `agents.tmp-${Date.now()}`);
180
182
  await fs.mkdir(tempDir, { recursive: true });
@@ -310,7 +312,7 @@ async function propagateSubagentsForClaude(projectRoot, subagents, options) {
310
312
  name: getSourceRelativeMdPath(s),
311
313
  content: buildClaudeFile(s),
312
314
  }));
313
- await writeAgentsDirectoryAtomic(targetDir, files);
315
+ await writeAgentsDirectoryAtomic(targetDir, files, projectRoot);
314
316
  return [];
315
317
  }
316
318
  async function propagateSubagentsForCursor(projectRoot, subagents, options) {
@@ -324,7 +326,7 @@ async function propagateSubagentsForCursor(projectRoot, subagents, options) {
324
326
  name: getSourceRelativeMdPath(s),
325
327
  content: buildCursorFile(s),
326
328
  }));
327
- await writeAgentsDirectoryAtomic(targetDir, files);
329
+ await writeAgentsDirectoryAtomic(targetDir, files, projectRoot);
328
330
  return [];
329
331
  }
330
332
  async function propagateSubagentsForCodex(projectRoot, subagents, options) {
@@ -338,7 +340,7 @@ async function propagateSubagentsForCodex(projectRoot, subagents, options) {
338
340
  name: withExtension(getSourceRelativeMdPath(s), '.toml'),
339
341
  content: buildCodexFile(s),
340
342
  }));
341
- await writeAgentsDirectoryAtomic(targetDir, files);
343
+ await writeAgentsDirectoryAtomic(targetDir, files, projectRoot);
342
344
  return [];
343
345
  }
344
346
  async function propagateSubagentsForCopilot(projectRoot, subagents, options) {
@@ -361,7 +363,7 @@ async function propagateSubagentsForCopilot(projectRoot, subagents, options) {
361
363
  name: getSourceRelativeMdPath(s),
362
364
  content: buildCopilotFile(s, false, verbose).content,
363
365
  }));
364
- await writeAgentsDirectoryAtomic(targetDir, files);
366
+ await writeAgentsDirectoryAtomic(targetDir, files, projectRoot);
365
367
  return [];
366
368
  }
367
369
  /* ------------------------------------------------------------------ */
@@ -379,6 +381,7 @@ async function cleanupSubagentsDir(projectRoot, relPath, dryRun, verbose) {
379
381
  (0, constants_1.logVerboseInfo)(`DRY RUN: Would remove ${relPath}`, verbose, dryRun);
380
382
  return;
381
383
  }
384
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(target, projectRoot, 'Refusing to remove subagents directory through symlinked path');
382
385
  await fs.rm(target, { recursive: true, force: true });
383
386
  (0, constants_1.logVerboseInfo)(`Removed ${relPath} (subagents disabled)`, verbose, dryRun);
384
387
  }
@@ -36,10 +36,56 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.loadUnifiedConfig = loadUnifiedConfig;
37
37
  const fs_1 = require("fs");
38
38
  const path = __importStar(require("path"));
39
+ const os = __importStar(require("os"));
39
40
  const toml_1 = require("@iarna/toml");
40
41
  const hash_1 = require("./hash");
41
42
  const RuleProcessor_1 = require("./RuleProcessor");
42
43
  const FileSystemUtils = __importStar(require("./FileSystemUtils"));
44
+ const KNOWN_MCP_SERVER_FIELDS = new Set([
45
+ 'type',
46
+ 'command',
47
+ 'args',
48
+ 'env',
49
+ 'url',
50
+ 'headers',
51
+ 'timeout',
52
+ ]);
53
+ function copyAdditionalMcpServerFields(def) {
54
+ return Object.fromEntries(Object.entries(def).filter(([key]) => !KNOWN_MCP_SERVER_FIELDS.has(key)));
55
+ }
56
+ async function resolveImplicitTomlFile(projectRoot, rulerDir, checkGlobal) {
57
+ const localTomlFile = path.join(rulerDir, 'ruler.toml');
58
+ if (await fileExists(localTomlFile)) {
59
+ return localTomlFile;
60
+ }
61
+ const projectTomlFile = path.join(projectRoot, '.ruler', 'ruler.toml');
62
+ if (path.resolve(projectTomlFile) !== path.resolve(localTomlFile) &&
63
+ (await fileExists(projectTomlFile))) {
64
+ return projectTomlFile;
65
+ }
66
+ if (!checkGlobal) {
67
+ return undefined;
68
+ }
69
+ const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
70
+ const globalTomlFile = path.join(xdgConfigDir, 'ruler', 'ruler.toml');
71
+ if (path.resolve(globalTomlFile) !== path.resolve(localTomlFile) &&
72
+ (await fileExists(globalTomlFile))) {
73
+ return globalTomlFile;
74
+ }
75
+ return undefined;
76
+ }
77
+ async function fileExists(filePath) {
78
+ try {
79
+ await fs_1.promises.access(filePath);
80
+ return true;
81
+ }
82
+ catch (err) {
83
+ if (err.code === 'ENOENT') {
84
+ return false;
85
+ }
86
+ throw err;
87
+ }
88
+ }
43
89
  async function loadUnifiedConfig(options) {
44
90
  // Resolve the effective .ruler directory (local or global), mirroring the main loader behavior
45
91
  const resolvedRulerDir = (await FileSystemUtils.findRulerDir(options.projectRoot, options.checkGlobal ?? true)) || path.join(options.projectRoot, '.ruler');
@@ -54,15 +100,14 @@ async function loadUnifiedConfig(options) {
54
100
  let tomlRaw = {};
55
101
  const tomlFile = options.configPath
56
102
  ? path.resolve(options.configPath)
57
- : path.join(meta.rulerDir, 'ruler.toml');
58
- try {
59
- const text = await fs_1.promises.readFile(tomlFile, 'utf8');
60
- tomlRaw = text.trim() ? (0, toml_1.parse)(text) : {};
61
- meta.configFile = tomlFile;
62
- }
63
- catch (err) {
64
- if (options.configPath ||
65
- err.code !== 'ENOENT') {
103
+ : await resolveImplicitTomlFile(options.projectRoot, meta.rulerDir, options.checkGlobal ?? true);
104
+ if (tomlFile) {
105
+ try {
106
+ const text = await fs_1.promises.readFile(tomlFile, 'utf8');
107
+ tomlRaw = text.trim() ? (0, toml_1.parse)(text) : {};
108
+ meta.configFile = tomlFile;
109
+ }
110
+ catch (err) {
66
111
  diagnostics.push({
67
112
  severity: options.configPath ? 'error' : 'warning',
68
113
  code: 'TOML_READ_ERROR',
@@ -169,7 +214,7 @@ async function loadUnifiedConfig(options) {
169
214
  if (!def || typeof def !== 'object')
170
215
  continue;
171
216
  const serverDef = def;
172
- const server = {};
217
+ const server = copyAdditionalMcpServerFields(serverDef);
173
218
  // Parse command and args
174
219
  if (typeof serverDef.command === 'string') {
175
220
  server.command = serverDef.command;
@@ -319,7 +364,7 @@ async function loadUnifiedConfig(options) {
319
364
  });
320
365
  continue;
321
366
  }
322
- const server = {};
367
+ const server = copyAdditionalMcpServerFields(def);
323
368
  if (typeof def.command === 'string')
324
369
  server.command = def.command;
325
370
  if (Array.isArray(def.command))
@@ -337,6 +382,46 @@ async function loadUnifiedConfig(options) {
337
382
  if (typeof def.timeout === 'number') {
338
383
  server.timeout = def.timeout;
339
384
  }
385
+ const hasCommand = !!server.command;
386
+ const hasUrl = !!server.url;
387
+ if (!hasCommand && !hasUrl) {
388
+ diagnostics.push({
389
+ severity: 'warning',
390
+ code: 'MCP_JSON_INVALID_SERVER',
391
+ message: `MCP server '${name}' must have at least one of command or url`,
392
+ file: mcpFile,
393
+ });
394
+ continue;
395
+ }
396
+ if (hasCommand && hasUrl) {
397
+ diagnostics.push({
398
+ severity: 'warning',
399
+ code: 'MCP_JSON_FIELD_CONFLICT',
400
+ message: `MCP server '${name}' has both command and url - using url (remote)`,
401
+ file: mcpFile,
402
+ });
403
+ }
404
+ if (hasCommand && server.headers) {
405
+ diagnostics.push({
406
+ severity: 'warning',
407
+ code: 'MCP_JSON_FIELD_CONFLICT',
408
+ message: `MCP server '${name}' has headers with command (should be used with url only)`,
409
+ file: mcpFile,
410
+ });
411
+ }
412
+ if (hasUrl && server.env) {
413
+ diagnostics.push({
414
+ severity: 'warning',
415
+ code: 'MCP_JSON_FIELD_CONFLICT',
416
+ message: `MCP server '${name}' has env with url (should be used with command only)`,
417
+ file: mcpFile,
418
+ });
419
+ }
420
+ if (hasCommand && hasUrl) {
421
+ delete server.command;
422
+ delete server.args;
423
+ delete server.env;
424
+ }
340
425
  // Derive type
341
426
  if (server.url)
342
427
  server.type = 'remote';
@@ -35,6 +35,7 @@ export interface AgentTomlConfig {
35
35
  outputPathInstructions?: string;
36
36
  outputPathConfig?: string;
37
37
  mcp?: McpConfig;
38
+ mcpServers?: Record<string, McpServerDef>;
38
39
  source: AgentConfigSourceMeta;
39
40
  }
40
41
  export interface AgentConfigSourceMeta {
@@ -61,7 +62,8 @@ export interface McpBundle {
61
62
  hash: string;
62
63
  }
63
64
  export interface McpServerDef {
64
- type?: 'stdio' | 'local' | 'remote';
65
+ [key: string]: unknown;
66
+ type?: string;
65
67
  command?: string;
66
68
  args?: string[];
67
69
  env?: Record<string, string>;
@@ -22,11 +22,15 @@ function agentMatchesFilter(agent, filter, validAgentIdentifiers) {
22
22
  function resolveSelectedAgents(config, allAgents) {
23
23
  // CLI --agents > config.default_agents > per-agent.enabled flags > default all
24
24
  let selected = allAgents;
25
+ const validAgentIdentifiers = new Set(allAgents.map((agent) => agent.getIdentifier()));
26
+ const validAgentNames = new Set(allAgents.map((agent) => agent.getName().toLowerCase()));
27
+ const invalidConfiguredAgents = Object.keys(config.agentConfigs).filter((identifier) => !validAgentIdentifiers.has(identifier));
28
+ if (invalidConfiguredAgents.length > 0) {
29
+ throw (0, constants_1.createRulerError)(`Invalid agent configured: ${invalidConfiguredAgents.join(', ')}`, `Valid agents are: ${[...validAgentIdentifiers].join(', ')}`);
30
+ }
25
31
  if (config.cliAgents && config.cliAgents.length > 0) {
26
32
  const filters = config.cliAgents.map((n) => n.toLowerCase());
27
33
  // Check if any of the specified agents don't exist
28
- const validAgentIdentifiers = new Set(allAgents.map((agent) => agent.getIdentifier()));
29
- const validAgentNames = new Set(allAgents.map((agent) => agent.getName().toLowerCase()));
30
34
  const invalidAgents = filters.filter((filter) => !validAgentIdentifiers.has(filter) &&
31
35
  ![...validAgentNames].some((name) => name.includes(filter)));
32
36
  if (invalidAgents.length > 0) {
@@ -37,8 +41,6 @@ function resolveSelectedAgents(config, allAgents) {
37
41
  else if (config.defaultAgents && config.defaultAgents.length > 0) {
38
42
  const defaults = config.defaultAgents.map((n) => n.toLowerCase());
39
43
  // Check if any of the default agents don't exist
40
- const validAgentIdentifiers = new Set(allAgents.map((agent) => agent.getIdentifier()));
41
- const validAgentNames = new Set(allAgents.map((agent) => agent.getName().toLowerCase()));
42
44
  const invalidAgents = defaults.filter((filter) => !validAgentIdentifiers.has(filter) &&
43
45
  ![...validAgentNames].some((name) => name.includes(filter)));
44
46
  if (invalidAgents.length > 0) {
@@ -8,6 +8,7 @@ export interface RulerConfiguration {
8
8
  config: LoadedConfig;
9
9
  concatenatedRules: string;
10
10
  rulerMcpJson: Record<string, unknown> | null;
11
+ projectRoot: string;
11
12
  }
12
13
  /**
13
14
  * Configuration data for a specific .ruler directory in hierarchical mode