@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
package/dist/constants.js CHANGED
@@ -53,7 +53,7 @@ function logVerboseInfo(message, isVerbose, dryRun = false) {
53
53
  exports.SKILLS_DIR = 'skills';
54
54
  exports.RULER_SKILLS_PATH = '.ruler/skills';
55
55
  exports.CLAUDE_SKILLS_PATH = '.claude/skills';
56
- exports.CODEX_SKILLS_PATH = '.codex/skills';
56
+ exports.CODEX_SKILLS_PATH = '.agents/skills';
57
57
  exports.OPENCODE_SKILLS_PATH = '.opencode/skills';
58
58
  exports.PI_SKILLS_PATH = '.pi/skills';
59
59
  exports.GOOSE_SKILLS_PATH = '.agents/skills';
@@ -11,6 +11,8 @@ export interface IAgentConfig {
11
11
  outputPathConfig?: string;
12
12
  /** MCP propagation config for this agent. */
13
13
  mcp?: McpConfig;
14
+ /** Agent-scoped MCP server definitions. */
15
+ mcpServers?: Record<string, Record<string, unknown>>;
14
16
  }
15
17
  /**
16
18
  * Parsed ruler configuration values.
@@ -40,6 +40,7 @@ const path = __importStar(require("path"));
40
40
  const os = __importStar(require("os"));
41
41
  const toml_1 = require("@iarna/toml");
42
42
  const zod_1 = require("zod");
43
+ const path_utils_1 = require("./path-utils");
43
44
  const constants_1 = require("../constants");
44
45
  // One-shot guard so the deprecation message fires once per process even when
45
46
  // `loadConfig` is called multiple times (e.g. nested mode walks every
@@ -61,6 +62,7 @@ const mcpConfigSchema = zod_1.z
61
62
  enabled: zod_1.z.boolean().optional(),
62
63
  merge_strategy: zod_1.z.enum(['merge', 'overwrite']).optional(),
63
64
  })
65
+ .strict()
64
66
  .optional();
65
67
  const agentConfigSchema = zod_1.z
66
68
  .object({
@@ -69,7 +71,11 @@ const agentConfigSchema = zod_1.z
69
71
  output_path_instructions: zod_1.z.string().optional(),
70
72
  output_path_config: zod_1.z.string().optional(),
71
73
  mcp: mcpConfigSchema,
74
+ mcp_servers: zod_1.z
75
+ .record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()))
76
+ .optional(),
72
77
  })
78
+ .strict()
73
79
  .optional();
74
80
  // `[agents]` is a heterogeneous table that holds two unrelated kinds of keys:
75
81
  // - reserved subagent-control booleans (`enabled`, `include_in_rules`)
@@ -81,7 +87,8 @@ const SUBAGENT_RESERVED_KEYS = new Set([
81
87
  'include_in_rules',
82
88
  'cleanup_orphaned',
83
89
  ]);
84
- const rulerConfigSchema = zod_1.z.object({
90
+ const rulerConfigSchema = zod_1.z
91
+ .object({
85
92
  default_agents: zod_1.z.array(zod_1.z.string()).optional(),
86
93
  agents: zod_1.z
87
94
  .object({
@@ -96,22 +103,29 @@ const rulerConfigSchema = zod_1.z.object({
96
103
  enabled: zod_1.z.boolean().optional(),
97
104
  merge_strategy: zod_1.z.enum(['merge', 'overwrite']).optional(),
98
105
  })
106
+ .strict()
107
+ .optional(),
108
+ mcp_servers: zod_1.z
109
+ .record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()))
99
110
  .optional(),
100
111
  gitignore: zod_1.z
101
112
  .object({
102
113
  enabled: zod_1.z.boolean().optional(),
103
114
  local: zod_1.z.boolean().optional(),
104
115
  })
116
+ .strict()
105
117
  .optional(),
106
118
  backup: zod_1.z
107
119
  .object({
108
120
  enabled: zod_1.z.boolean().optional(),
109
121
  })
122
+ .strict()
110
123
  .optional(),
111
124
  skills: zod_1.z
112
125
  .object({
113
126
  enabled: zod_1.z.boolean().optional(),
114
127
  })
128
+ .strict()
115
129
  .optional(),
116
130
  // Deprecated: kept in the schema only so that legacy `[subagents]` blocks
117
131
  // are preserved through validation. The parser reads from here as a
@@ -123,9 +137,11 @@ const rulerConfigSchema = zod_1.z.object({
123
137
  include_in_rules: zod_1.z.boolean().optional(),
124
138
  cleanup_orphaned: zod_1.z.boolean().optional(),
125
139
  })
140
+ .strict()
126
141
  .optional(),
127
142
  nested: zod_1.z.boolean().optional(),
128
- });
143
+ })
144
+ .strict();
129
145
  /**
130
146
  * Recursively creates a new object with only enumerable string keys,
131
147
  * effectively excluding Symbol properties.
@@ -147,6 +163,32 @@ function stripSymbols(obj) {
147
163
  }
148
164
  return result;
149
165
  }
166
+ function parseAgentMcpServers(sectionObj) {
167
+ if (!sectionObj.mcp_servers ||
168
+ typeof sectionObj.mcp_servers !== 'object' ||
169
+ Array.isArray(sectionObj.mcp_servers)) {
170
+ return undefined;
171
+ }
172
+ const servers = {};
173
+ for (const [name, def] of Object.entries(sectionObj.mcp_servers)) {
174
+ if (def && typeof def === 'object' && !Array.isArray(def)) {
175
+ servers[name] = normalizeAgentMcpServer(def);
176
+ }
177
+ }
178
+ return Object.keys(servers).length > 0 ? servers : undefined;
179
+ }
180
+ function normalizeAgentMcpServer(def) {
181
+ const server = { ...def };
182
+ const hasCommand = typeof server.command === 'string';
183
+ const hasUrl = typeof server.url === 'string';
184
+ if (hasCommand && hasUrl) {
185
+ delete server.command;
186
+ delete server.args;
187
+ delete server.env;
188
+ server.type = 'remote';
189
+ }
190
+ return server;
191
+ }
150
192
  /**
151
193
  * Loads and parses the ruler TOML configuration file, applying defaults.
152
194
  * Missing implicit configs return defaults. Explicit configs and existing
@@ -179,13 +221,13 @@ async function loadConfig(options) {
179
221
  cfg.enabled = sectionObj.enabled;
180
222
  }
181
223
  if (typeof sectionObj.output_path === 'string') {
182
- cfg.outputPath = path.resolve(projectRoot, sectionObj.output_path);
224
+ cfg.outputPath = resolveProjectOutputPath(projectRoot, sectionObj.output_path, configFile, `[agents.${name}].output_path`);
183
225
  }
184
226
  if (typeof sectionObj.output_path_instructions === 'string') {
185
- cfg.outputPathInstructions = path.resolve(projectRoot, sectionObj.output_path_instructions);
227
+ cfg.outputPathInstructions = resolveProjectOutputPath(projectRoot, sectionObj.output_path_instructions, configFile, `[agents.${name}].output_path_instructions`);
186
228
  }
187
229
  if (typeof sectionObj.output_path_config === 'string') {
188
- cfg.outputPathConfig = path.resolve(projectRoot, sectionObj.output_path_config);
230
+ cfg.outputPathConfig = resolveProjectOutputPath(projectRoot, sectionObj.output_path_config, configFile, `[agents.${name}].output_path_config`);
189
231
  }
190
232
  if (sectionObj.mcp && typeof sectionObj.mcp === 'object') {
191
233
  const m = sectionObj.mcp;
@@ -201,6 +243,7 @@ async function loadConfig(options) {
201
243
  }
202
244
  cfg.mcp = mcpCfg;
203
245
  }
246
+ cfg.mcpServers = parseAgentMcpServers(sectionObj);
204
247
  agentConfigs[name] = cfg;
205
248
  }
206
249
  }
@@ -301,6 +344,20 @@ async function loadConfig(options) {
301
344
  nestedDefined,
302
345
  };
303
346
  }
347
+ function resolveProjectOutputPath(projectRoot, configuredPath, configFile, fieldName) {
348
+ const resolvedPath = path.resolve(projectRoot, configuredPath);
349
+ if (!(0, path_utils_1.isPathInsideOrEqual)(projectRoot, resolvedPath)) {
350
+ throw (0, constants_1.createRulerError)('Configured output path is outside the project root', [
351
+ configFile ? `File: ${configFile}` : undefined,
352
+ `Field: ${fieldName}`,
353
+ `Path: ${configuredPath}`,
354
+ `Project root: ${projectRoot}`,
355
+ ]
356
+ .filter(Boolean)
357
+ .join(', '));
358
+ }
359
+ return resolvedPath;
360
+ }
304
361
  async function resolveImplicitConfigFile(projectRoot, checkGlobal) {
305
362
  const localRulerDir = await findNearestLocalRulerDir(projectRoot);
306
363
  const localConfigFile = localRulerDir
@@ -381,8 +438,18 @@ function parseConfigText(text, configFile) {
381
438
  function validateConfig(raw, configFile) {
382
439
  const validationResult = rulerConfigSchema.safeParse(raw);
383
440
  if (!validationResult.success) {
384
- throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ')}`);
441
+ throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map(formatZodIssue).join(', ')}`);
442
+ }
443
+ }
444
+ function formatZodIssue(issue) {
445
+ const basePath = issue.path.join('.');
446
+ if (issue.code === 'unrecognized_keys') {
447
+ const keys = issue.keys;
448
+ return keys
449
+ .map((key) => (basePath ? `${basePath}.${key}` : key))
450
+ .join(', ');
385
451
  }
452
+ return `${basePath}: ${issue.message}`;
386
453
  }
387
454
  function errorMessage(err) {
388
455
  return err instanceof Error ? err.message : String(err);
@@ -1,9 +1,11 @@
1
+ export declare function assertManagedPathInsideRoot(managedPath: string, rootPath: string, action: string): Promise<void>;
1
2
  /**
2
3
  * Searches upwards from startPath to find a directory named .ruler.
3
4
  * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
4
5
  * Returns the path to the .ruler directory, or null if not found.
5
6
  */
6
7
  export declare function findRulerDir(startPath: string, checkGlobal?: boolean): Promise<string | null>;
8
+ export declare function resolveProjectRootForRulerDir(requestedProjectRoot: string, rulerDir: string): string;
7
9
  /**
8
10
  * Options for {@link readMarkdownFiles}.
9
11
  */
@@ -29,12 +31,12 @@ export declare function readMarkdownFiles(rulerDir: string, options?: ReadMarkdo
29
31
  /**
30
32
  * Writes content to filePath, creating parent directories if necessary.
31
33
  */
32
- export declare function writeGeneratedFile(filePath: string, content: string): Promise<void>;
34
+ export declare function writeGeneratedFile(filePath: string, content: string, containmentRoot?: string): Promise<void>;
33
35
  /**
34
36
  * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
35
37
  * Keeps an existing backup intact so repeated applies preserve the original file.
36
38
  */
37
- export declare function backupFile(filePath: string): Promise<void>;
39
+ export declare function backupFile(filePath: string, containmentRoot?: string): Promise<void>;
38
40
  /**
39
41
  * Ensures that the given directory exists by creating it recursively.
40
42
  */
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.assertManagedPathInsideRoot = assertManagedPathInsideRoot;
36
37
  exports.findRulerDir = findRulerDir;
38
+ exports.resolveProjectRootForRulerDir = resolveProjectRootForRulerDir;
37
39
  exports.readMarkdownFiles = readMarkdownFiles;
38
40
  exports.writeGeneratedFile = writeGeneratedFile;
39
41
  exports.backupFile = backupFile;
@@ -44,7 +46,9 @@ const fs_1 = require("fs");
44
46
  const path = __importStar(require("path"));
45
47
  const os = __importStar(require("os"));
46
48
  const constants_1 = require("../constants");
49
+ const path_utils_1 = require("./path-utils");
47
50
  const SUBAGENTS_DIR_NAME = path.basename(constants_1.RULER_SUBAGENTS_PATH);
51
+ const RULER_GENERATED_MARKER = '<!-- Generated by Ruler -->';
48
52
  const DEFAULT_NESTED_DISCOVERY_IGNORES = new Set([
49
53
  '__fixtures__',
50
54
  '__generated__',
@@ -66,6 +70,66 @@ function getXdgConfigDir() {
66
70
  function shouldSkipNestedDiscoveryDir(dirName) {
67
71
  return (dirName.startsWith('.') || DEFAULT_NESTED_DISCOVERY_IGNORES.has(dirName));
68
72
  }
73
+ async function isSymbolicLink(filePath) {
74
+ try {
75
+ return (await fs_1.promises.lstat(filePath)).isSymbolicLink();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ async function assertNotSymbolicLink(filePath, action) {
82
+ if (await isSymbolicLink(filePath)) {
83
+ throw new Error(`${action}: ${filePath}`);
84
+ }
85
+ }
86
+ async function assertContainingDirectoryInsideRoot(filePath, rootPath, action) {
87
+ const realRoot = await fs_1.promises.realpath(rootPath);
88
+ let current = path.dirname(path.resolve(filePath));
89
+ while (true) {
90
+ try {
91
+ const realCurrent = await fs_1.promises.realpath(current);
92
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realCurrent)) {
93
+ throw new Error(`${action}: ${filePath}`);
94
+ }
95
+ return;
96
+ }
97
+ catch (error) {
98
+ if (error.code !== 'ENOENT') {
99
+ throw error;
100
+ }
101
+ }
102
+ const parent = path.dirname(current);
103
+ if (parent === current) {
104
+ return;
105
+ }
106
+ current = parent;
107
+ }
108
+ }
109
+ async function assertManagedPathInsideRoot(managedPath, rootPath, action) {
110
+ const realRoot = await fs_1.promises.realpath(rootPath);
111
+ await assertContainingDirectoryInsideRoot(managedPath, rootPath, action);
112
+ try {
113
+ const realManagedPath = await fs_1.promises.realpath(managedPath);
114
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realManagedPath)) {
115
+ throw new Error(`${action}: ${managedPath}`);
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'ENOENT') {
120
+ throw error;
121
+ }
122
+ }
123
+ }
124
+ async function isRulerGeneratedFile(filePath) {
125
+ try {
126
+ const content = await fs_1.promises.readFile(filePath, 'utf8');
127
+ return content.startsWith(RULER_GENERATED_MARKER);
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
69
133
  /**
70
134
  * Searches upwards from startPath to find a directory named .ruler.
71
135
  * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
@@ -101,11 +165,19 @@ async function findRulerDir(startPath, checkGlobal = true) {
101
165
  }
102
166
  }
103
167
  catch (err) {
168
+ if (err.code === 'ENOENT') {
169
+ return null;
170
+ }
104
171
  console.error(`[ruler] Error checking global config directory ${globalConfigDir}:`, err);
105
172
  }
106
173
  }
107
174
  return null;
108
175
  }
176
+ function resolveProjectRootForRulerDir(requestedProjectRoot, rulerDir) {
177
+ return path.basename(rulerDir) === '.ruler'
178
+ ? path.dirname(rulerDir)
179
+ : requestedProjectRoot;
180
+ }
109
181
  /**
110
182
  * Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
111
183
  * Files are sorted alphabetically by path.
@@ -116,6 +188,8 @@ async function findRulerDir(startPath, checkGlobal = true) {
116
188
  async function readMarkdownFiles(rulerDir, options = {}) {
117
189
  const mdFiles = [];
118
190
  const includeAgents = options.includeAgents === true;
191
+ const realRulerDir = await fs_1.promises.realpath(rulerDir);
192
+ const visitedDirectories = new Set();
119
193
  // Tracks whether we skipped a `.ruler/agents` subtree so the root-AGENTS.md
120
194
  // fallback below still recognises ruler content as present and does not
121
195
  // resurrect a previously generated root AGENTS.md (which may itself contain
@@ -123,6 +197,17 @@ async function readMarkdownFiles(rulerDir, options = {}) {
123
197
  let sawExcludedAgents = false;
124
198
  // Gather all markdown files (recursive) first
125
199
  async function walk(dir) {
200
+ let realDir;
201
+ try {
202
+ realDir = await fs_1.promises.realpath(dir);
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ if (visitedDirectories.has(realDir)) {
208
+ return;
209
+ }
210
+ visitedDirectories.add(realDir);
126
211
  const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
127
212
  for (const entry of entries) {
128
213
  const fullPath = path.join(dir, entry.name);
@@ -131,6 +216,10 @@ async function readMarkdownFiles(rulerDir, options = {}) {
131
216
  let isFile = entry.isFile();
132
217
  if (entry.isSymbolicLink()) {
133
218
  try {
219
+ const realTarget = await fs_1.promises.realpath(fullPath);
220
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRulerDir, realTarget)) {
221
+ continue;
222
+ }
134
223
  const stat = await fs_1.promises.stat(fullPath);
135
224
  isDir = stat.isDirectory();
136
225
  isFile = stat.isFile();
@@ -201,7 +290,19 @@ async function readMarkdownFiles(rulerDir, options = {}) {
201
290
  const repoRoot = path.dirname(rulerDir); // .ruler parent
202
291
  const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
203
292
  if (path.resolve(rootAgentsPath) !== path.resolve(topLevelAgents)) {
204
- const stat = await fs_1.promises.stat(rootAgentsPath);
293
+ const rootAgentsStat = await fs_1.promises.lstat(rootAgentsPath);
294
+ if (rootAgentsStat.isSymbolicLink()) {
295
+ const [realRepoRoot, realRootAgentsPath] = await Promise.all([
296
+ fs_1.promises.realpath(repoRoot),
297
+ fs_1.promises.realpath(rootAgentsPath),
298
+ ]);
299
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRepoRoot, realRootAgentsPath)) {
300
+ return ordered;
301
+ }
302
+ }
303
+ const stat = rootAgentsStat.isSymbolicLink()
304
+ ? await fs_1.promises.stat(rootAgentsPath)
305
+ : rootAgentsStat;
205
306
  if (stat.isFile()) {
206
307
  const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
207
308
  // Check if this is a generated file and we have other .ruler files.
@@ -231,16 +332,29 @@ async function readMarkdownFiles(rulerDir, options = {}) {
231
332
  /**
232
333
  * Writes content to filePath, creating parent directories if necessary.
233
334
  */
234
- async function writeGeneratedFile(filePath, content) {
335
+ async function writeGeneratedFile(filePath, content, containmentRoot) {
336
+ if (containmentRoot) {
337
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
338
+ }
235
339
  await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
340
+ await assertNotSymbolicLink(filePath, 'Refusing to write generated file through symlink');
341
+ if (containmentRoot) {
342
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
343
+ }
236
344
  await fs_1.promises.writeFile(filePath, content, 'utf8');
237
345
  }
238
346
  /**
239
347
  * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
240
348
  * Keeps an existing backup intact so repeated applies preserve the original file.
241
349
  */
242
- async function backupFile(filePath) {
350
+ async function backupFile(filePath, containmentRoot) {
243
351
  const backupPath = `${filePath}.bak`;
352
+ if (containmentRoot) {
353
+ await assertManagedPathInsideRoot(filePath, containmentRoot, 'Refusing to back up generated file through symlinked directory');
354
+ await assertManagedPathInsideRoot(backupPath, containmentRoot, 'Refusing to create backup file through symlinked directory');
355
+ }
356
+ await assertNotSymbolicLink(filePath, 'Refusing to back up symlinked file');
357
+ await assertNotSymbolicLink(backupPath, 'Refusing to use symlinked backup file');
244
358
  try {
245
359
  await fs_1.promises.access(backupPath);
246
360
  return;
@@ -248,6 +362,9 @@ async function backupFile(filePath) {
248
362
  catch {
249
363
  // continue if no backup exists yet
250
364
  }
365
+ if (await isRulerGeneratedFile(filePath)) {
366
+ return;
367
+ }
251
368
  try {
252
369
  await fs_1.promises.copyFile(filePath, backupPath);
253
370
  }
@@ -1,3 +1,11 @@
1
+ export interface RulerBlockRange {
2
+ start: number;
3
+ end: number;
4
+ }
5
+ export interface RemoveRulerBlocksResult {
6
+ content: string;
7
+ removed: boolean;
8
+ }
1
9
  /**
2
10
  * Updates an ignore file in the project root with paths in a managed Ruler block.
3
11
  * Creates the file if it doesn't exist, and creates or updates the Ruler-managed block.
@@ -13,3 +21,5 @@ export declare function updateGitignore(projectRoot: string, paths: string[], ig
13
21
  * through that pointer.
14
22
  */
15
23
  export declare function resolveIgnoreFilePath(projectRoot: string, ignoreFile: string): Promise<string>;
24
+ export declare function findCompleteRulerBlocks(lines: string[]): RulerBlockRange[];
25
+ export declare function removeCompleteRulerBlocks(content: string): RemoveRulerBlocksResult;
@@ -35,8 +35,11 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.updateGitignore = updateGitignore;
37
37
  exports.resolveIgnoreFilePath = resolveIgnoreFilePath;
38
+ exports.findCompleteRulerBlocks = findCompleteRulerBlocks;
39
+ exports.removeCompleteRulerBlocks = removeCompleteRulerBlocks;
38
40
  const fs_1 = require("fs");
39
41
  const path = __importStar(require("path"));
42
+ const FileSystemUtils_1 = require("./FileSystemUtils");
40
43
  const RULER_START_MARKER = '# START Ruler Generated Files';
41
44
  const RULER_END_MARKER = '# END Ruler Generated Files';
42
45
  /**
@@ -99,8 +102,7 @@ async function updateGitignore(projectRoot, paths, ignoreFile = '.gitignore') {
99
102
  // Create new content
100
103
  const newContent = updateGitignoreContent(existingContent, allRulerPaths);
101
104
  // Write the updated content
102
- await fs_1.promises.mkdir(path.dirname(gitignorePath), { recursive: true });
103
- await fs_1.promises.writeFile(gitignorePath, newContent);
105
+ await (0, FileSystemUtils_1.writeGeneratedFile)(gitignorePath, newContent);
104
106
  }
105
107
  /**
106
108
  * Resolves ignore files Ruler manages. Linked worktrees store `.git` as a
@@ -137,56 +139,85 @@ async function resolveIgnoreFilePath(projectRoot, ignoreFile) {
137
139
  */
138
140
  function getExistingPathsExcludingRulerBlock(content) {
139
141
  const lines = content.split('\n');
142
+ const rulerBlocks = findCompleteRulerBlocks(lines);
140
143
  const paths = [];
141
- let inRulerBlock = false;
142
- for (const line of lines) {
143
- const trimmed = line.trim();
144
- if (trimmed === RULER_START_MARKER) {
145
- inRulerBlock = true;
146
- continue;
147
- }
148
- if (trimmed === RULER_END_MARKER) {
149
- inRulerBlock = false;
144
+ for (const [index, line] of lines.entries()) {
145
+ if (rulerBlocks.some((block) => index >= block.start && index <= block.end)) {
150
146
  continue;
151
147
  }
152
- if (!inRulerBlock && trimmed && !trimmed.startsWith('#')) {
148
+ const trimmed = line.trim();
149
+ if (trimmed && !trimmed.startsWith('#')) {
153
150
  paths.push(trimmed);
154
151
  }
155
152
  }
156
153
  return paths;
157
154
  }
155
+ function findCompleteRulerBlocks(lines) {
156
+ const ranges = [];
157
+ for (let index = 0; index < lines.length; index++) {
158
+ if (lines[index].trim() !== RULER_START_MARKER) {
159
+ continue;
160
+ }
161
+ for (let endIndex = index + 1; endIndex < lines.length; endIndex++) {
162
+ const trimmed = lines[endIndex].trim();
163
+ if (trimmed === RULER_START_MARKER) {
164
+ break;
165
+ }
166
+ if (trimmed === RULER_END_MARKER) {
167
+ ranges.push({ start: index, end: endIndex });
168
+ index = endIndex;
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ return ranges;
174
+ }
175
+ function removeCompleteRulerBlocks(content) {
176
+ const lines = content.split('\n');
177
+ const rulerBlocks = findCompleteRulerBlocks(lines);
178
+ if (rulerBlocks.length === 0) {
179
+ return { content, removed: false };
180
+ }
181
+ const retainedLines = [];
182
+ for (let index = 0; index < lines.length; index++) {
183
+ const block = rulerBlocks.find((range) => range.start === index);
184
+ if (block) {
185
+ index = block.end;
186
+ continue;
187
+ }
188
+ retainedLines.push(lines[index]);
189
+ }
190
+ return {
191
+ content: retainedLines.join('\n').replace(/\n{3,}/g, '\n\n'),
192
+ removed: true,
193
+ };
194
+ }
158
195
  /**
159
196
  * Updates the .gitignore content by replacing or adding the Ruler block.
160
197
  */
161
198
  function updateGitignoreContent(existingContent, rulerPaths) {
162
199
  const lines = existingContent.split('\n');
200
+ const rulerBlocks = findCompleteRulerBlocks(lines);
163
201
  const newLines = [];
164
- let inFirstRulerBlock = false;
165
- let hasRulerBlock = false;
166
- let processedFirstBlock = false;
167
- for (const line of lines) {
168
- const trimmed = line.trim();
169
- if (trimmed === RULER_START_MARKER && !processedFirstBlock) {
170
- inFirstRulerBlock = true;
171
- hasRulerBlock = true;
172
- newLines.push(line);
173
- // Add the new Ruler paths
202
+ let replacedFirstBlock = false;
203
+ for (let index = 0; index < lines.length; index++) {
204
+ const block = rulerBlocks.find((range) => range.start === index);
205
+ if (block && !replacedFirstBlock) {
206
+ newLines.push(lines[block.start]);
174
207
  rulerPaths.forEach((p) => newLines.push(p));
208
+ newLines.push(lines[block.end]);
209
+ replacedFirstBlock = true;
210
+ index = block.end;
175
211
  continue;
176
212
  }
177
- if (trimmed === RULER_END_MARKER && inFirstRulerBlock) {
178
- inFirstRulerBlock = false;
179
- processedFirstBlock = true;
180
- newLines.push(line);
213
+ if (block) {
214
+ index = block.end;
181
215
  continue;
182
216
  }
183
- if (!inFirstRulerBlock) {
184
- newLines.push(line);
185
- }
186
- // Skip lines that are in the first Ruler block (they get replaced)
217
+ newLines.push(lines[index]);
187
218
  }
188
219
  // If no Ruler block exists, add one at the end
189
- if (!hasRulerBlock) {
220
+ if (rulerBlocks.length === 0) {
190
221
  // Add blank line if content exists and doesn't end with blank line
191
222
  if (existingContent.trim() && !existingContent.endsWith('\n\n')) {
192
223
  newLines.push('');
@@ -15,7 +15,7 @@ export declare function discoverSkills(projectRoot: string): Promise<{
15
15
  */
16
16
  export declare function getSkillsGitignorePaths(projectRoot: string, agents: IAgent[]): Promise<string[]>;
17
17
  type ReplaceSkillsFsOps = Pick<typeof fs, 'rename' | 'cp' | 'rm'>;
18
- export declare function replaceSkillsDirectory(tempDir: string, targetDir: string, fsOps?: ReplaceSkillsFsOps): Promise<void>;
18
+ export declare function replaceSkillsDirectory(tempDir: string, targetDir: string, fsOps?: ReplaceSkillsFsOps, containmentRoot?: string): Promise<void>;
19
19
  /**
20
20
  * Propagates skills for agents that need them.
21
21
  */
@@ -29,7 +29,7 @@ export declare function propagateSkillsForClaude(projectRoot: string, options: {
29
29
  dryRun: boolean;
30
30
  }): Promise<string[]>;
31
31
  /**
32
- * Propagates skills for OpenAI Codex CLI by copying .ruler/skills to .codex/skills.
32
+ * Propagates skills for OpenAI Codex CLI by copying .ruler/skills to .agents/skills.
33
33
  * Uses atomic replace to ensure safe overwriting of existing skills.
34
34
  * Returns dry-run steps if dryRun is true, otherwise returns empty array.
35
35
  */