@intellectronica/ruler 0.3.42 → 0.3.43

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 (50) hide show
  1. package/README.md +97 -10
  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/CrushAgent.d.ts +1 -1
  9. package/dist/agents/CrushAgent.js +15 -6
  10. package/dist/agents/FirebenderAgent.js +5 -4
  11. package/dist/agents/GeminiCliAgent.d.ts +1 -0
  12. package/dist/agents/GeminiCliAgent.js +11 -5
  13. package/dist/agents/IAgent.d.ts +2 -0
  14. package/dist/agents/MistralVibeAgent.js +14 -3
  15. package/dist/agents/OpenCodeAgent.d.ts +1 -1
  16. package/dist/agents/OpenCodeAgent.js +10 -3
  17. package/dist/agents/QwenCodeAgent.d.ts +1 -0
  18. package/dist/agents/QwenCodeAgent.js +9 -3
  19. package/dist/agents/RooCodeAgent.js +3 -2
  20. package/dist/agents/ZedAgent.js +3 -3
  21. package/dist/constants.d.ts +1 -1
  22. package/dist/constants.js +1 -1
  23. package/dist/core/ConfigLoader.d.ts +2 -0
  24. package/dist/core/ConfigLoader.js +73 -6
  25. package/dist/core/FileSystemUtils.d.ts +4 -2
  26. package/dist/core/FileSystemUtils.js +120 -3
  27. package/dist/core/GitignoreUtils.d.ts +10 -0
  28. package/dist/core/GitignoreUtils.js +62 -31
  29. package/dist/core/SkillsProcessor.d.ts +2 -2
  30. package/dist/core/SkillsProcessor.js +46 -37
  31. package/dist/core/SubagentsProcessor.js +8 -5
  32. package/dist/core/UnifiedConfigLoader.js +54 -2
  33. package/dist/core/UnifiedConfigTypes.d.ts +3 -1
  34. package/dist/core/agent-selection.js +6 -4
  35. package/dist/core/apply-engine.d.ts +1 -0
  36. package/dist/core/apply-engine.js +38 -15
  37. package/dist/core/revert-engine.d.ts +2 -1
  38. package/dist/core/revert-engine.js +73 -26
  39. package/dist/lib.js +9 -6
  40. package/dist/mcp/merge.js +28 -26
  41. package/dist/mcp/propagateOpenCodeMcp.d.ts +1 -1
  42. package/dist/mcp/propagateOpenCodeMcp.js +10 -3
  43. package/dist/mcp/propagateOpenHandsMcp.d.ts +1 -1
  44. package/dist/mcp/propagateOpenHandsMcp.js +18 -7
  45. package/dist/paths/mcp.d.ts +1 -1
  46. package/dist/paths/mcp.js +11 -4
  47. package/dist/revert.js +27 -27
  48. package/dist/vscode/settings.d.ts +1 -1
  49. package/dist/vscode/settings.js +3 -3
  50. package/package.json +4 -3
@@ -105,6 +105,7 @@ async function createHierarchicalConfiguration(rulerDir, files, config, cliConfi
105
105
  config,
106
106
  concatenatedRules,
107
107
  rulerMcpJson,
108
+ projectRoot: directoryRoot,
108
109
  };
109
110
  }
110
111
  async function loadConfigForRulerDir(rulerDir, cliConfigPath, resolvedNested, localOnly) {
@@ -139,6 +140,12 @@ function cloneLoadedConfig(config) {
139
140
  clonedAgentConfigs[agent] = {
140
141
  ...agentConfig,
141
142
  mcp: agentConfig.mcp ? { ...agentConfig.mcp } : undefined,
143
+ mcpServers: agentConfig.mcpServers
144
+ ? Object.fromEntries(Object.entries(agentConfig.mcpServers).map(([name, server]) => [
145
+ name,
146
+ { ...server },
147
+ ]))
148
+ : undefined,
142
149
  };
143
150
  }
144
151
  return {
@@ -194,9 +201,10 @@ async function loadSingleConfiguration(projectRoot, configPath, localOnly) {
194
201
  const { dirs: rulerDirs, primaryDir } = await findRulerDirectories(projectRoot, localOnly, false);
195
202
  // Warn about legacy mcp.json
196
203
  await warnAboutLegacyMcpJson(primaryDir);
204
+ const effectiveProjectRoot = FileSystemUtils.resolveProjectRootForRulerDir(projectRoot, primaryDir);
197
205
  // Load the ruler.toml configuration
198
206
  const config = await (0, ConfigLoader_1.loadConfig)({
199
- projectRoot,
207
+ projectRoot: effectiveProjectRoot,
200
208
  configPath,
201
209
  checkGlobal: !localOnly,
202
210
  });
@@ -210,7 +218,7 @@ async function loadSingleConfiguration(projectRoot, configPath, localOnly) {
210
218
  // Load unified config to get merged MCP configuration
211
219
  const { loadUnifiedConfig } = await Promise.resolve().then(() => __importStar(require('./UnifiedConfigLoader')));
212
220
  const unifiedConfig = await loadUnifiedConfig({
213
- projectRoot,
221
+ projectRoot: effectiveProjectRoot,
214
222
  configPath,
215
223
  checkGlobal: !localOnly,
216
224
  });
@@ -225,6 +233,7 @@ async function loadSingleConfiguration(projectRoot, configPath, localOnly) {
225
233
  config,
226
234
  concatenatedRules,
227
235
  rulerMcpJson,
236
+ projectRoot: effectiveProjectRoot,
228
237
  };
229
238
  }
230
239
  /**
@@ -282,7 +291,7 @@ async function applyConfigurationsToAgents(agents, concatenatedRules, rulerMcpJs
282
291
  (0, constants_1.logInfo)(`Applying rules for ${agent.getName()}...`, dryRun);
283
292
  (0, constants_1.logVerbose)(`Processing agent: ${agent.getName()}`, verbose);
284
293
  const agentConfig = config.agentConfigs[agent.getIdentifier()];
285
- const agentRulerMcpJson = rulerMcpJson;
294
+ const agentRulerMcpJson = mergeAgentMcpServers(rulerMcpJson, agentConfig);
286
295
  // Collect output paths for .gitignore
287
296
  const outputPaths = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
288
297
  (0, constants_1.logVerbose)(`Agent ${agent.getName()} output paths: ${outputPaths.join(', ')}`, verbose);
@@ -358,6 +367,19 @@ async function handleMcpConfiguration(agent, agentConfig, config, rulerMcpJson,
358
367
  function shouldUseEngineManagedMcp(agent) {
359
368
  return (agent.getIdentifier() === 'codex' || agent.getIdentifier() === 'opencode');
360
369
  }
370
+ function mergeAgentMcpServers(rulerMcpJson, agentConfig) {
371
+ const baseServers = rulerMcpJson?.mcpServers && typeof rulerMcpJson.mcpServers === 'object'
372
+ ? rulerMcpJson.mcpServers
373
+ : {};
374
+ const agentServers = agentConfig?.mcpServers ?? {};
375
+ const mergedServers = {
376
+ ...baseServers,
377
+ ...agentServers,
378
+ };
379
+ return Object.keys(mergedServers).length > 0
380
+ ? { mcpServers: mergedServers }
381
+ : null;
382
+ }
361
383
  async function resolveMcpDestination(agent, agentConfig, projectRoot) {
362
384
  if (agentConfig?.outputPathConfig) {
363
385
  return path.resolve(projectRoot, agentConfig.outputPathConfig);
@@ -412,10 +434,10 @@ async function applyMcpConfiguration(agent, filteredMcpJson, dest, agentConfig,
412
434
  }
413
435
  const agentMcpJson = sanitizeMcpTimeoutsForAgent(agent, filteredMcpJson, dryRun);
414
436
  if (agent.getIdentifier() === 'openhands') {
415
- return await applyOpenHandsMcpConfiguration(agentMcpJson, dest, cliMcpStrategy ?? agentConfig?.mcp?.strategy ?? config.mcp?.strategy, dryRun, verbose, backup);
437
+ return await applyOpenHandsMcpConfiguration(agentMcpJson, dest, projectRoot, cliMcpStrategy ?? agentConfig?.mcp?.strategy ?? config.mcp?.strategy, dryRun, verbose, backup);
416
438
  }
417
439
  if (agent.getIdentifier() === 'opencode') {
418
- return await applyOpenCodeMcpConfiguration(agentMcpJson, dest, cliMcpStrategy ?? agentConfig?.mcp?.strategy ?? config.mcp?.strategy, dryRun, verbose, backup);
440
+ return await applyOpenCodeMcpConfiguration(agentMcpJson, dest, projectRoot, cliMcpStrategy ?? agentConfig?.mcp?.strategy ?? config.mcp?.strategy, dryRun, verbose, backup);
419
441
  }
420
442
  // Agents that handle MCP configuration internally should not have external MCP handling
421
443
  if (agent.getIdentifier() === 'zed' ||
@@ -425,22 +447,22 @@ async function applyMcpConfiguration(agent, filteredMcpJson, dest, agentConfig,
425
447
  (0, constants_1.logVerbose)(`Skipping external MCP config for ${agent.getName()} - handled internally by agent`, verbose);
426
448
  return;
427
449
  }
428
- return await applyStandardMcpConfiguration(agent, agentMcpJson, dest, agentConfig, config, cliMcpStrategy, dryRun, verbose, backup);
450
+ return await applyStandardMcpConfiguration(agent, agentMcpJson, dest, agentConfig, config, projectRoot, cliMcpStrategy, dryRun, verbose, backup);
429
451
  }
430
- async function applyOpenHandsMcpConfiguration(filteredMcpJson, dest, strategy, dryRun, verbose, backup = true) {
452
+ async function applyOpenHandsMcpConfiguration(filteredMcpJson, dest, projectRoot, strategy, dryRun, verbose, backup = true) {
431
453
  if (dryRun) {
432
454
  (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating TOML file: ${dest}`, verbose);
433
455
  }
434
456
  else {
435
- await (0, propagateOpenHandsMcp_1.propagateMcpToOpenHands)(filteredMcpJson, dest, backup, strategy);
457
+ await (0, propagateOpenHandsMcp_1.propagateMcpToOpenHands)(filteredMcpJson, dest, backup, strategy, projectRoot);
436
458
  }
437
459
  }
438
- async function applyOpenCodeMcpConfiguration(filteredMcpJson, dest, strategy, dryRun, verbose, backup = true) {
460
+ async function applyOpenCodeMcpConfiguration(filteredMcpJson, dest, projectRoot, strategy, dryRun, verbose, backup = true) {
439
461
  if (dryRun) {
440
462
  (0, constants_1.logVerbose)(`DRY RUN: Would apply MCP config by updating OpenCode config file: ${dest}`, verbose);
441
463
  }
442
464
  else {
443
- await (0, propagateOpenCodeMcp_1.propagateMcpToOpenCode)(filteredMcpJson, dest, backup, strategy);
465
+ await (0, propagateOpenCodeMcp_1.propagateMcpToOpenCode)(filteredMcpJson, dest, backup, strategy, projectRoot);
444
466
  }
445
467
  }
446
468
  /**
@@ -536,7 +558,7 @@ function transformMcpForFactoryDroid(mcpJson) {
536
558
  transformedMcp.mcpServers = transformedServers;
537
559
  return transformedMcp;
538
560
  }
539
- async function applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, cliMcpStrategy, dryRun, verbose, backup = true) {
561
+ async function applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agentConfig, config, projectRoot, cliMcpStrategy, dryRun, verbose, backup = true) {
540
562
  const strategy = cliMcpStrategy ??
541
563
  agentConfig?.mcp?.strategy ??
542
564
  config.mcp?.strategy ??
@@ -554,7 +576,8 @@ async function applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agent
554
576
  else {
555
577
  // Transform MCP config for agent-specific compatibility
556
578
  let mcpToMerge = filteredMcpJson;
557
- if (agent.getIdentifier() === 'claude') {
579
+ if (agent.getIdentifier() === 'claude' ||
580
+ agent.getIdentifier() === 'aider') {
558
581
  mcpToMerge = transformMcpForClaude(filteredMcpJson);
559
582
  }
560
583
  else if (agent.getIdentifier() === 'kilocode') {
@@ -633,13 +656,13 @@ async function applyStandardMcpConfiguration(agent, filteredMcpJson, dest, agent
633
656
  if (currentContent !== newContent) {
634
657
  if (backup) {
635
658
  const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
636
- await backupFile(dest);
659
+ await backupFile(dest, projectRoot);
637
660
  }
638
661
  if (isCodexToml) {
639
- await FileSystemUtils.writeGeneratedFile(dest, (0, toml_1.stringify)(toWrite));
662
+ await FileSystemUtils.writeGeneratedFile(dest, (0, toml_1.stringify)(toWrite), projectRoot);
640
663
  }
641
664
  else {
642
- await (0, mcp_1.writeNativeMcp)(dest, toWrite);
665
+ await (0, mcp_1.writeNativeMcp)(dest, toWrite, projectRoot);
643
666
  }
644
667
  }
645
668
  else {
@@ -20,7 +20,7 @@ export interface CleanUpResult {
20
20
  * @param agent The agent to revert
21
21
  * @param projectRoot Root directory of the project
22
22
  * @param agentConfig Agent-specific configuration
23
- * @param keepBackups Whether to keep backup files
23
+ * @param keepBackups Whether restored backup files should be preserved
24
24
  * @param verbose Whether to enable verbose logging
25
25
  * @param dryRun Whether to perform a dry run
26
26
  * @returns Promise resolving to revert statistics
@@ -34,3 +34,4 @@ export declare function revertAgentConfiguration(agent: IAgent, projectRoot: str
34
34
  * @returns Promise resolving to cleanup statistics
35
35
  */
36
36
  export declare function cleanUpAuxiliaryFiles(projectRoot: string, verbose: boolean, dryRun: boolean): Promise<CleanUpResult>;
37
+ export declare function cleanUpAgentDirectories(agent: IAgent, projectRoot: string, agentConfig: IAgentConfig | undefined, verbose: boolean, dryRun: boolean): Promise<number>;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.revertAgentConfiguration = revertAgentConfiguration;
37
37
  exports.cleanUpAuxiliaryFiles = cleanUpAuxiliaryFiles;
38
+ exports.cleanUpAgentDirectories = cleanUpAgentDirectories;
38
39
  const path = __importStar(require("path"));
39
40
  const fs_1 = require("fs");
40
41
  const agent_utils_1 = require("../agents/agent-utils");
@@ -43,9 +44,20 @@ const constants_1 = require("../constants");
43
44
  const GitignoreUtils_1 = require("./GitignoreUtils");
44
45
  const settings_1 = require("../vscode/settings");
45
46
  const path_utils_1 = require("./path-utils");
47
+ const FileSystemUtils_1 = require("./FileSystemUtils");
46
48
  const RULER_START_MARKER = '# START Ruler Generated Files';
47
49
  const RULER_END_MARKER = '# END Ruler Generated Files';
48
50
  const RULER_GENERATED_MARKER = '<!-- Generated by Ruler -->';
51
+ const RULER_SOURCE_MARKER_PREFIXES = [
52
+ '<!-- Source: .ruler/',
53
+ '<!-- Source: ruler/',
54
+ ];
55
+ async function resolveMcpPathForRevert(agent, projectRoot, agentConfig) {
56
+ if (agentConfig?.outputPathConfig) {
57
+ return path.resolve(projectRoot, agentConfig.outputPathConfig);
58
+ }
59
+ return await (0, mcp_1.getNativeMcpPath)(agent.getName(), projectRoot);
60
+ }
49
61
  /**
50
62
  * Checks if a file exists.
51
63
  */
@@ -90,6 +102,10 @@ async function hasRulerGeneratedProvenance(filePath, projectRoot) {
90
102
  if (content.startsWith(RULER_GENERATED_MARKER)) {
91
103
  return true;
92
104
  }
105
+ const trimmedContent = content.trimStart();
106
+ if (RULER_SOURCE_MARKER_PREFIXES.some((prefix) => trimmedContent.startsWith(prefix))) {
107
+ return true;
108
+ }
93
109
  }
94
110
  catch {
95
111
  return false;
@@ -109,7 +125,7 @@ async function hasRulerGeneratedProvenance(filePath, projectRoot) {
109
125
  /**
110
126
  * Restores a file from its backup if the backup exists.
111
127
  */
112
- async function restoreFromBackup(filePath, verbose, dryRun) {
128
+ async function restoreFromBackup(filePath, verbose, dryRun, projectRoot) {
113
129
  const backupPath = `${filePath}.bak`;
114
130
  const backupExists = await fileExists(backupPath);
115
131
  if (!backupExists) {
@@ -121,6 +137,10 @@ async function restoreFromBackup(filePath, verbose, dryRun) {
121
137
  (0, constants_1.logVerbose)(`${prefix} Would restore: ${filePath} from backup`, verbose);
122
138
  }
123
139
  else {
140
+ if (projectRoot) {
141
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(filePath, projectRoot, 'Refusing to restore backup through symlinked output path');
142
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(backupPath, projectRoot, 'Refusing to restore from backup through symlinked backup path');
143
+ }
124
144
  await fs_1.promises.copyFile(backupPath, filePath);
125
145
  (0, constants_1.logVerbose)(`${prefix} Restored: ${filePath} from backup`, verbose);
126
146
  }
@@ -150,6 +170,9 @@ async function removeGeneratedFile(filePath, verbose, dryRun, projectRoot) {
150
170
  (0, constants_1.logVerbose)(`${prefix} Would remove generated file: ${filePath}`, verbose);
151
171
  }
152
172
  else {
173
+ if (projectRoot) {
174
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(filePath, projectRoot, 'Refusing to remove generated file through symlinked path');
175
+ }
153
176
  await fs_1.promises.unlink(filePath);
154
177
  (0, constants_1.logVerbose)(`${prefix} Removed generated file: ${filePath}`, verbose);
155
178
  }
@@ -158,7 +181,7 @@ async function removeGeneratedFile(filePath, verbose, dryRun, projectRoot) {
158
181
  /**
159
182
  * Removes backup files.
160
183
  */
161
- async function removeBackupFile(filePath, verbose, dryRun) {
184
+ async function removeBackupFile(filePath, verbose, dryRun, projectRoot) {
162
185
  const backupPath = `${filePath}.bak`;
163
186
  const backupExists = await fileExists(backupPath);
164
187
  if (!backupExists) {
@@ -169,6 +192,9 @@ async function removeBackupFile(filePath, verbose, dryRun) {
169
192
  (0, constants_1.logVerbose)(`${prefix} Would remove backup file: ${backupPath}`, verbose);
170
193
  }
171
194
  else {
195
+ if (projectRoot) {
196
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(backupPath, projectRoot, 'Refusing to remove backup file through symlinked path');
197
+ }
172
198
  await fs_1.promises.unlink(backupPath);
173
199
  (0, constants_1.logVerbose)(`${prefix} Removed backup file: ${backupPath}`, verbose);
174
200
  }
@@ -320,7 +346,7 @@ async function removeAdditionalAgentFiles(projectRoot, verbose, dryRun) {
320
346
  }
321
347
  const backupExists = await fileExists(`${fullPath}.bak`);
322
348
  if (backupExists) {
323
- const restored = await restoreFromBackup(fullPath, verbose, dryRun);
349
+ const restored = await restoreFromBackup(fullPath, verbose, dryRun, projectRoot);
324
350
  if (restored) {
325
351
  filesRemoved++;
326
352
  }
@@ -346,7 +372,7 @@ async function removeAdditionalAgentFiles(projectRoot, verbose, dryRun) {
346
372
  const settingsPath = (0, settings_1.getVSCodeSettingsPath)(projectRoot);
347
373
  const backupPath = `${settingsPath}.bak`;
348
374
  if (await fileExists(backupPath)) {
349
- const restored = await restoreFromBackup(settingsPath, verbose, dryRun);
375
+ const restored = await restoreFromBackup(settingsPath, verbose, dryRun, projectRoot);
350
376
  if (restored) {
351
377
  filesRemoved++;
352
378
  (0, constants_1.logVerbose)(`${prefix} Restored VSCode settings from backup`, verbose);
@@ -374,11 +400,12 @@ async function removeAdditionalAgentFiles(projectRoot, verbose, dryRun) {
374
400
  delete settings['augment.advanced'];
375
401
  const remainingKeys = Object.keys(settings);
376
402
  if (remainingKeys.length === 0) {
403
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(settingsPath, projectRoot, 'Refusing to remove VSCode settings through symlinked path');
377
404
  await fs_1.promises.unlink(settingsPath);
378
405
  (0, constants_1.logVerbose)(`${prefix} Removed empty VSCode settings file`, verbose);
379
406
  }
380
407
  else {
381
- await (0, settings_1.writeVSCodeSettings)(settingsPath, settings);
408
+ await (0, settings_1.writeVSCodeSettings)(settingsPath, settings, projectRoot);
382
409
  (0, constants_1.logVerbose)(`${prefix} Removed augment.advanced section from VSCode settings`, verbose);
383
410
  }
384
411
  filesRemoved++;
@@ -399,7 +426,7 @@ async function removeAdditionalAgentFiles(projectRoot, verbose, dryRun) {
399
426
  * @param agent The agent to revert
400
427
  * @param projectRoot Root directory of the project
401
428
  * @param agentConfig Agent-specific configuration
402
- * @param keepBackups Whether to keep backup files
429
+ * @param keepBackups Whether restored backup files should be preserved
403
430
  * @param verbose Whether to enable verbose logging
404
431
  * @param dryRun Whether to perform a dry run
405
432
  * @returns Promise resolving to revert statistics
@@ -411,13 +438,20 @@ async function revertAgentConfiguration(agent, projectRoot, agentConfig, keepBac
411
438
  backupsRemoved: 0,
412
439
  };
413
440
  const outputPaths = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
441
+ const processedPaths = new Set();
414
442
  (0, constants_1.logVerbose)(`Agent ${agent.getName()} output paths: ${outputPaths.join(', ')}`, verbose);
415
- for (const outputPath of outputPaths) {
416
- const restored = await restoreFromBackup(outputPath, verbose, dryRun);
443
+ const processPath = async (outputPath) => {
444
+ const resolvedPath = path.resolve(projectRoot, outputPath);
445
+ if (processedPaths.has(resolvedPath)) {
446
+ (0, constants_1.logVerbose)(`Skipping already processed path: ${outputPath}`, verbose);
447
+ return;
448
+ }
449
+ processedPaths.add(resolvedPath);
450
+ const restored = await restoreFromBackup(outputPath, verbose, dryRun, projectRoot);
417
451
  if (restored) {
418
452
  result.restored++;
419
453
  if (!keepBackups) {
420
- const backupRemoved = await removeBackupFile(outputPath, verbose, dryRun);
454
+ const backupRemoved = await removeBackupFile(outputPath, verbose, dryRun, projectRoot);
421
455
  if (backupRemoved) {
422
456
  result.backupsRemoved++;
423
457
  }
@@ -429,31 +463,19 @@ async function revertAgentConfiguration(agent, projectRoot, agentConfig, keepBac
429
463
  result.removed++;
430
464
  }
431
465
  }
466
+ };
467
+ for (const outputPath of outputPaths) {
468
+ await processPath(outputPath);
432
469
  }
433
470
  // Handle MCP files
434
- const mcpPath = await (0, mcp_1.getNativeMcpPath)(agent.getName(), projectRoot);
471
+ const mcpPath = await resolveMcpPathForRevert(agent, projectRoot, agentConfig);
435
472
  if (mcpPath && (0, path_utils_1.isPathInsideOrEqual)(projectRoot, mcpPath)) {
436
473
  if (agent.getName() === 'AugmentCode' &&
437
474
  mcpPath.endsWith('.vscode/settings.json')) {
438
475
  (0, constants_1.logVerbose)(`Skipping MCP handling for AugmentCode settings.json - handled separately`, verbose);
439
476
  }
440
477
  else {
441
- const mcpRestored = await restoreFromBackup(mcpPath, verbose, dryRun);
442
- if (mcpRestored) {
443
- result.restored++;
444
- if (!keepBackups) {
445
- const mcpBackupRemoved = await removeBackupFile(mcpPath, verbose, dryRun);
446
- if (mcpBackupRemoved) {
447
- result.backupsRemoved++;
448
- }
449
- }
450
- }
451
- else {
452
- const mcpRemoved = await removeGeneratedFile(mcpPath, verbose, dryRun, projectRoot);
453
- if (mcpRemoved) {
454
- result.removed++;
455
- }
456
- }
478
+ await processPath(mcpPath);
457
479
  }
458
480
  }
459
481
  return result;
@@ -473,3 +495,28 @@ async function cleanUpAuxiliaryFiles(projectRoot, verbose, dryRun) {
473
495
  directoriesRemoved,
474
496
  };
475
497
  }
498
+ async function cleanUpAgentDirectories(agent, projectRoot, agentConfig, verbose, dryRun) {
499
+ const candidateFiles = (0, agent_utils_1.getAgentOutputPaths)(agent, projectRoot, agentConfig);
500
+ const mcpPath = await (0, mcp_1.getNativeMcpPath)(agent.getName(), projectRoot);
501
+ if (mcpPath && (0, path_utils_1.isPathInsideOrEqual)(projectRoot, mcpPath)) {
502
+ candidateFiles.push(mcpPath);
503
+ }
504
+ const candidateDirs = [
505
+ ...new Set(candidateFiles.map((filePath) => path.dirname(filePath))),
506
+ ];
507
+ let directoriesRemoved = 0;
508
+ const resolvedProjectRoot = path.resolve(projectRoot);
509
+ for (const candidateDir of candidateDirs) {
510
+ let currentDir = path.resolve(candidateDir);
511
+ while (currentDir !== resolvedProjectRoot &&
512
+ (0, path_utils_1.isPathInsideOrEqual)(resolvedProjectRoot, currentDir)) {
513
+ const removed = await removeEmptyDirectory(currentDir, verbose, dryRun);
514
+ if (!removed) {
515
+ break;
516
+ }
517
+ directoriesRemoved++;
518
+ currentDir = path.dirname(currentDir);
519
+ }
520
+ }
521
+ return directoriesRemoved;
522
+ }
package/dist/lib.js CHANGED
@@ -102,6 +102,7 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
102
102
  let selectedAgents;
103
103
  let generatedPaths;
104
104
  let loadedConfig;
105
+ let outputProjectRoot = projectRoot;
105
106
  if (nested) {
106
107
  const hierarchicalConfigs = await (0, apply_engine_1.loadNestedConfigurations)(projectRoot, configPath, localOnly, nested);
107
108
  if (hierarchicalConfigs.length === 0) {
@@ -147,6 +148,8 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
147
148
  }
148
149
  else {
149
150
  const singleConfig = await (0, apply_engine_1.loadSingleConfiguration)(projectRoot, configPath, localOnly);
151
+ const singleProjectRoot = singleConfig.projectRoot;
152
+ outputProjectRoot = singleProjectRoot;
150
153
  loadedConfig = singleConfig.config;
151
154
  singleConfig.config.cliAgents = includedAgents;
152
155
  (0, constants_1.logVerbose)(`Loaded configuration with ${Object.keys(singleConfig.config.agentConfigs).length} agent configs`, verbose);
@@ -158,7 +161,7 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
158
161
  const skillsEnabledResolved = resolveSkillsEnabled(skillsEnabled, singleConfig.config.skills?.enabled);
159
162
  if (skillsEnabledResolved) {
160
163
  const { propagateSkills } = await Promise.resolve().then(() => __importStar(require('./core/SkillsProcessor')));
161
- await propagateSkills(projectRoot, selectedAgents, skillsEnabledResolved, verbose, dryRun);
164
+ await propagateSkills(singleProjectRoot, selectedAgents, skillsEnabledResolved, verbose, dryRun);
162
165
  }
163
166
  // Propagate subagents (mirrors skills handling).
164
167
  const subagentsEnabledResolvedSingle = resolveSubagentsEnabled(subagentsEnabled, singleConfig.config.subagents?.enabled);
@@ -166,9 +169,9 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
166
169
  const backupEnabledResolvedSingle = resolveBackupEnabled(backup, singleConfig.config.backup?.enabled);
167
170
  {
168
171
  const { propagateSubagents } = await Promise.resolve().then(() => __importStar(require('./core/SubagentsProcessor')));
169
- await propagateSubagents(projectRoot, selectedAgents, subagentsEnabledResolvedSingle, subagentsCleanupOrphanedSingle, verbose, dryRun);
172
+ await propagateSubagents(singleProjectRoot, selectedAgents, subagentsEnabledResolvedSingle, subagentsCleanupOrphanedSingle, verbose, dryRun);
170
173
  }
171
- generatedPaths = await (0, apply_engine_1.processSingleConfiguration)(selectedAgents, singleConfig, projectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backupEnabledResolvedSingle);
174
+ generatedPaths = await (0, apply_engine_1.processSingleConfiguration)(selectedAgents, singleConfig, singleProjectRoot, verbose, dryRun, cliMcpEnabled, cliMcpStrategy, backupEnabledResolvedSingle);
172
175
  }
173
176
  // Add skills-generated paths to gitignore if skills are enabled
174
177
  let allGeneratedPaths = generatedPaths;
@@ -176,17 +179,17 @@ async function applyAllAgentConfigs(projectRoot, includedAgents, configPath, cli
176
179
  if (skillsEnabledForGitignore) {
177
180
  // Skills enabled by default or explicitly
178
181
  const { getSkillsGitignorePaths } = await Promise.resolve().then(() => __importStar(require('./core/SkillsProcessor')));
179
- const skillsPaths = await getSkillsGitignorePaths(projectRoot, selectedAgents);
182
+ const skillsPaths = await getSkillsGitignorePaths(outputProjectRoot, selectedAgents);
180
183
  allGeneratedPaths = [...allGeneratedPaths, ...skillsPaths];
181
184
  }
182
185
  // Add subagents-generated paths to gitignore if subagents are enabled.
183
186
  const subagentsEnabledForGitignore = resolveSubagentsEnabled(subagentsEnabled, loadedConfig.subagents?.enabled);
184
187
  if (subagentsEnabledForGitignore) {
185
188
  const { getSubagentsGitignorePaths } = await Promise.resolve().then(() => __importStar(require('./core/SubagentsProcessor')));
186
- const subagentPaths = await getSubagentsGitignorePaths(projectRoot, selectedAgents);
189
+ const subagentPaths = await getSubagentsGitignorePaths(outputProjectRoot, selectedAgents);
187
190
  allGeneratedPaths = [...allGeneratedPaths, ...subagentPaths];
188
191
  }
189
- await (0, apply_engine_1.updateGitignore)(projectRoot, allGeneratedPaths, loadedConfig, cliGitignoreEnabled, dryRun, cliGitignoreLocal);
192
+ await (0, apply_engine_1.updateGitignore)(outputProjectRoot, allGeneratedPaths, loadedConfig, cliGitignoreEnabled, dryRun, cliGitignoreLocal);
190
193
  }
191
194
  /**
192
195
  * Normalizes per-agent config keys to agent identifiers for consistent lookup.
package/dist/mcp/merge.js CHANGED
@@ -8,6 +8,29 @@ const MCP_SERVER_KEYS = [
8
8
  'mcp_servers',
9
9
  'context_servers',
10
10
  ];
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ function collectMcpServers(config, serverKey) {
15
+ const servers = {};
16
+ const aliases = MCP_SERVER_KEYS.filter((key) => key !== serverKey);
17
+ for (const key of [...aliases, serverKey]) {
18
+ const value = config[key];
19
+ if (isRecord(value)) {
20
+ Object.assign(servers, value);
21
+ }
22
+ }
23
+ return servers;
24
+ }
25
+ function removeServerAliases(config, serverKey) {
26
+ const result = { ...config };
27
+ for (const key of MCP_SERVER_KEYS) {
28
+ if (key !== serverKey) {
29
+ delete result[key];
30
+ }
31
+ }
32
+ return result;
33
+ }
11
34
  /**
12
35
  * Merge native and incoming MCP server configurations according to strategy.
13
36
  * @param base Existing native MCP config object.
@@ -18,38 +41,17 @@ const MCP_SERVER_KEYS = [
18
41
  */
19
42
  function mergeMcp(base, incoming, strategy, serverKey) {
20
43
  if (strategy === 'overwrite') {
21
- // Ensure the incoming object uses the correct server key.
22
- // Transform from the standard (Crush) MCP config format
23
- const incomingServers = incoming[serverKey] ||
24
- incoming.mcpServers ||
25
- incoming.mcp ||
26
- {};
27
- const preservedBase = { ...base };
28
- for (const key of MCP_SERVER_KEYS) {
29
- if (key !== serverKey) {
30
- delete preservedBase[key];
31
- }
32
- }
44
+ const incomingServers = collectMcpServers(incoming, serverKey);
45
+ const preservedBase = removeServerAliases(base, serverKey);
33
46
  return {
34
47
  ...preservedBase,
35
48
  [serverKey]: incomingServers,
36
49
  };
37
50
  }
38
- const baseServers = base[serverKey] ||
39
- base.mcpServers ||
40
- base.mcp ||
41
- {};
42
- const incomingServers = incoming[serverKey] ||
43
- incoming.mcpServers ||
44
- incoming.mcp ||
45
- {};
51
+ const baseServers = collectMcpServers(base, serverKey);
52
+ const incomingServers = collectMcpServers(incoming, serverKey);
46
53
  const mergedServers = { ...baseServers, ...incomingServers };
47
- const newBase = { ...base };
48
- for (const key of MCP_SERVER_KEYS) {
49
- if (key !== serverKey) {
50
- delete newBase[key];
51
- }
52
- }
54
+ const newBase = removeServerAliases(base, serverKey);
53
55
  return {
54
56
  ...newBase,
55
57
  [serverKey]: mergedServers,
@@ -1,2 +1,2 @@
1
1
  import { McpStrategy } from '../types';
2
- export declare function propagateMcpToOpenCode(rulerMcpData: Record<string, unknown> | null, openCodeConfigPath: string, backup?: boolean, strategy?: McpStrategy): Promise<void>;
2
+ export declare function propagateMcpToOpenCode(rulerMcpData: Record<string, unknown> | null, openCodeConfigPath: string, backup?: boolean, strategy?: McpStrategy, containmentRoot?: string): Promise<void>;
@@ -91,8 +91,11 @@ function transformToOpenCodeFormat(rulerMcp) {
91
91
  mcp: openCodeServers,
92
92
  };
93
93
  }
94
- async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath, backup = true, strategy = 'merge') {
94
+ async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath, backup = true, strategy = 'merge', containmentRoot) {
95
95
  const rulerMcp = rulerMcpData || {};
96
+ if (containmentRoot) {
97
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(openCodeConfigPath, containmentRoot, 'Refusing to write generated file outside project');
98
+ }
96
99
  // Read existing OpenCode config if it exists
97
100
  let existingConfig = {};
98
101
  let existingContent;
@@ -125,10 +128,14 @@ async function propagateMcpToOpenCode(rulerMcpData, openCodeConfigPath, backup =
125
128
  ...transformedConfig.mcp,
126
129
  },
127
130
  };
131
+ const finalContent = JSON.stringify(finalConfig, null, 2) + '\n';
132
+ if (existingContent === finalContent) {
133
+ return;
134
+ }
128
135
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(openCodeConfigPath));
129
136
  if (backup) {
130
137
  const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
131
- await backupFile(openCodeConfigPath);
138
+ await backupFile(openCodeConfigPath, containmentRoot);
132
139
  }
133
- await fs.writeFile(openCodeConfigPath, JSON.stringify(finalConfig, null, 2) + '\n');
140
+ await (0, FileSystemUtils_1.writeGeneratedFile)(openCodeConfigPath, finalContent, containmentRoot);
134
141
  }
@@ -1,2 +1,2 @@
1
1
  import { McpStrategy } from '../types';
2
- export declare function propagateMcpToOpenHands(rulerMcpData: Record<string, unknown> | null, openHandsConfigPath: string, backup?: boolean, strategy?: McpStrategy): Promise<void>;
2
+ export declare function propagateMcpToOpenHands(rulerMcpData: Record<string, unknown> | null, openHandsConfigPath: string, backup?: boolean, strategy?: McpStrategy, containmentRoot?: string): Promise<void>;
@@ -67,10 +67,14 @@ function extractApiKey(headers) {
67
67
  }
68
68
  return null;
69
69
  }
70
- function createRemoteServerEntry(url, headers) {
70
+ function createRemoteServerEntry(name, url, headers) {
71
+ const hasHeaders = headers && Object.keys(headers).length > 0;
71
72
  const apiKey = extractApiKey(headers);
72
- if (apiKey) {
73
- return { url, api_key: apiKey };
73
+ if (hasHeaders) {
74
+ if (apiKey) {
75
+ return { url, api_key: apiKey };
76
+ }
77
+ throw new Error(`OpenHands MCP remote server "${name}" has unsupported headers. OpenHands config.toml can only represent a Bearer Authorization header as api_key.`);
74
78
  }
75
79
  return url;
76
80
  }
@@ -89,8 +93,11 @@ function normalizeRemoteServerArray(entries) {
89
93
  // All entries are strings, keep as is
90
94
  return entries;
91
95
  }
92
- async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath, backup = true, strategy = 'merge') {
96
+ async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath, backup = true, strategy = 'merge', containmentRoot) {
93
97
  const rulerMcp = rulerMcpData || {};
98
+ if (containmentRoot) {
99
+ await (0, FileSystemUtils_1.assertManagedPathInsideRoot)(openHandsConfigPath, containmentRoot, 'Refusing to write generated file outside project');
100
+ }
94
101
  // Always use the legacy Ruler MCP config format as input (top-level "mcpServers" key)
95
102
  const rulerServers = rulerMcp.mcpServers || {};
96
103
  // Return early if no servers to process
@@ -162,7 +169,7 @@ async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath, backup
162
169
  else if (serverDef.url) {
163
170
  // Remote server
164
171
  const classification = classifyRemoteServer(serverDef.url);
165
- const entry = createRemoteServerEntry(serverDef.url, serverDef.headers);
172
+ const entry = createRemoteServerEntry(name, serverDef.url, serverDef.headers);
166
173
  if (classification === 'sse') {
167
174
  existingSseServers.set(serverDef.url, entry);
168
175
  }
@@ -176,10 +183,14 @@ async function propagateMcpToOpenHands(rulerMcpData, openHandsConfigPath, backup
176
183
  config.mcp.stdio_servers = Array.from(existingStdioServers.values());
177
184
  config.mcp.sse_servers = normalizeRemoteServerArray(Array.from(existingSseServers.values()));
178
185
  config.mcp.shttp_servers = normalizeRemoteServerArray(Array.from(existingShttpServers.values()));
186
+ const finalContent = (0, toml_1.stringify)(config);
187
+ if (tomlContent === finalContent) {
188
+ return;
189
+ }
179
190
  await (0, FileSystemUtils_1.ensureDirExists)(path.dirname(openHandsConfigPath));
180
191
  if (backup) {
181
192
  const { backupFile } = await Promise.resolve().then(() => __importStar(require('../core/FileSystemUtils')));
182
- await backupFile(openHandsConfigPath);
193
+ await backupFile(openHandsConfigPath, containmentRoot);
183
194
  }
184
- await fs.writeFile(openHandsConfigPath, (0, toml_1.stringify)(config));
195
+ await (0, FileSystemUtils_1.writeGeneratedFile)(openHandsConfigPath, finalContent, containmentRoot);
185
196
  }
@@ -5,4 +5,4 @@ export declare function readNativeMcp(filePath: string): Promise<Record<string,
5
5
  /** Read native Codex TOML MCP config from disk, or return empty object if missing. */
6
6
  export declare function readNativeMcpToml(filePath: string, parseToml: (text: string) => Record<string, unknown>): Promise<Record<string, unknown>>;
7
7
  /** Write native MCP config to disk, creating parent directories as needed. */
8
- export declare function writeNativeMcp(filePath: string, data: unknown): Promise<void>;
8
+ export declare function writeNativeMcp(filePath: string, data: unknown, containmentRoot?: string): Promise<void>;
package/dist/paths/mcp.js CHANGED
@@ -39,6 +39,10 @@ exports.readNativeMcpToml = readNativeMcpToml;
39
39
  exports.writeNativeMcp = writeNativeMcp;
40
40
  const path = __importStar(require("path"));
41
41
  const fs_1 = require("fs");
42
+ const FileSystemUtils_1 = require("../core/FileSystemUtils");
43
+ function isRecord(value) {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
42
46
  /** Determine the native MCP config path for a given agent. */
43
47
  async function getNativeMcpPath(adapterName, projectRoot) {
44
48
  const candidates = [];
@@ -125,7 +129,11 @@ async function readNativeMcp(filePath) {
125
129
  throw new Error(`Could not read MCP config at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
126
130
  }
127
131
  try {
128
- return JSON.parse(text);
132
+ const parsed = JSON.parse(text);
133
+ if (!isRecord(parsed)) {
134
+ throw new Error('must be a JSON object');
135
+ }
136
+ return parsed;
129
137
  }
130
138
  catch (error) {
131
139
  throw new Error(`Invalid MCP config at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
@@ -151,8 +159,7 @@ async function readNativeMcpToml(filePath, parseToml) {
151
159
  }
152
160
  }
153
161
  /** Write native MCP config to disk, creating parent directories as needed. */
154
- async function writeNativeMcp(filePath, data) {
155
- await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
162
+ async function writeNativeMcp(filePath, data, containmentRoot) {
156
163
  const text = JSON.stringify(data, null, 2) + '\n';
157
- await fs_1.promises.writeFile(filePath, text, 'utf8');
164
+ await (0, FileSystemUtils_1.writeGeneratedFile)(filePath, text, containmentRoot);
158
165
  }