@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
@@ -15,7 +15,7 @@ export declare function logVerboseInfo(message: string, isVerbose: boolean, dryR
15
15
  export declare const SKILLS_DIR = "skills";
16
16
  export declare const RULER_SKILLS_PATH = ".ruler/skills";
17
17
  export declare const CLAUDE_SKILLS_PATH = ".claude/skills";
18
- export declare const CODEX_SKILLS_PATH = ".codex/skills";
18
+ export declare const CODEX_SKILLS_PATH = ".agents/skills";
19
19
  export declare const OPENCODE_SKILLS_PATH = ".opencode/skills";
20
20
  export declare const PI_SKILLS_PATH = ".pi/skills";
21
21
  export declare const GOOSE_SKILLS_PATH = ".agents/skills";
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,46 @@ 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
+ const server = normalizeAgentMcpServer(def);
176
+ if (server.command || server.url) {
177
+ servers[name] = server;
178
+ }
179
+ }
180
+ }
181
+ return Object.keys(servers).length > 0 ? servers : undefined;
182
+ }
183
+ function normalizeAgentMcpServer(def) {
184
+ const server = { ...def };
185
+ const hasCommand = typeof server.command === 'string';
186
+ const hasUrl = typeof server.url === 'string';
187
+ if (!hasCommand) {
188
+ delete server.command;
189
+ delete server.args;
190
+ delete server.env;
191
+ }
192
+ if (!hasUrl) {
193
+ delete server.url;
194
+ delete server.headers;
195
+ delete server.auth;
196
+ delete server.oauth;
197
+ }
198
+ if (hasCommand && hasUrl) {
199
+ delete server.command;
200
+ delete server.args;
201
+ delete server.env;
202
+ server.type = 'remote';
203
+ }
204
+ return server;
205
+ }
150
206
  /**
151
207
  * Loads and parses the ruler TOML configuration file, applying defaults.
152
208
  * Missing implicit configs return defaults. Explicit configs and existing
@@ -179,13 +235,13 @@ async function loadConfig(options) {
179
235
  cfg.enabled = sectionObj.enabled;
180
236
  }
181
237
  if (typeof sectionObj.output_path === 'string') {
182
- cfg.outputPath = path.resolve(projectRoot, sectionObj.output_path);
238
+ cfg.outputPath = resolveProjectOutputPath(projectRoot, sectionObj.output_path, configFile, `[agents.${name}].output_path`);
183
239
  }
184
240
  if (typeof sectionObj.output_path_instructions === 'string') {
185
- cfg.outputPathInstructions = path.resolve(projectRoot, sectionObj.output_path_instructions);
241
+ cfg.outputPathInstructions = resolveProjectOutputPath(projectRoot, sectionObj.output_path_instructions, configFile, `[agents.${name}].output_path_instructions`);
186
242
  }
187
243
  if (typeof sectionObj.output_path_config === 'string') {
188
- cfg.outputPathConfig = path.resolve(projectRoot, sectionObj.output_path_config);
244
+ cfg.outputPathConfig = resolveProjectOutputPath(projectRoot, sectionObj.output_path_config, configFile, `[agents.${name}].output_path_config`);
189
245
  }
190
246
  if (sectionObj.mcp && typeof sectionObj.mcp === 'object') {
191
247
  const m = sectionObj.mcp;
@@ -201,6 +257,7 @@ async function loadConfig(options) {
201
257
  }
202
258
  cfg.mcp = mcpCfg;
203
259
  }
260
+ cfg.mcpServers = parseAgentMcpServers(sectionObj);
204
261
  agentConfigs[name] = cfg;
205
262
  }
206
263
  }
@@ -301,6 +358,20 @@ async function loadConfig(options) {
301
358
  nestedDefined,
302
359
  };
303
360
  }
361
+ function resolveProjectOutputPath(projectRoot, configuredPath, configFile, fieldName) {
362
+ const resolvedPath = path.resolve(projectRoot, configuredPath);
363
+ if (!(0, path_utils_1.isPathInsideOrEqual)(projectRoot, resolvedPath)) {
364
+ throw (0, constants_1.createRulerError)('Configured output path is outside the project root', [
365
+ configFile ? `File: ${configFile}` : undefined,
366
+ `Field: ${fieldName}`,
367
+ `Path: ${configuredPath}`,
368
+ `Project root: ${projectRoot}`,
369
+ ]
370
+ .filter(Boolean)
371
+ .join(', '));
372
+ }
373
+ return resolvedPath;
374
+ }
304
375
  async function resolveImplicitConfigFile(projectRoot, checkGlobal) {
305
376
  const localRulerDir = await findNearestLocalRulerDir(projectRoot);
306
377
  const localConfigFile = localRulerDir
@@ -381,8 +452,18 @@ function parseConfigText(text, configFile) {
381
452
  function validateConfig(raw, configFile) {
382
453
  const validationResult = rulerConfigSchema.safeParse(raw);
383
454
  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(', ')}`);
455
+ throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map(formatZodIssue).join(', ')}`);
456
+ }
457
+ }
458
+ function formatZodIssue(issue) {
459
+ const basePath = issue.path.join('.');
460
+ if (issue.code === 'unrecognized_keys') {
461
+ const keys = issue.keys;
462
+ return keys
463
+ .map((key) => (basePath ? `${basePath}.${key}` : key))
464
+ .join(', ');
385
465
  }
466
+ return `${basePath}: ${issue.message}`;
386
467
  }
387
468
  function errorMessage(err) {
388
469
  return err instanceof Error ? err.message : String(err);
@@ -1,9 +1,12 @@
1
+ export declare function assertNotSymbolicLink(filePath: string, action: string): Promise<void>;
2
+ export declare function assertManagedPathInsideRoot(managedPath: string, rootPath: string, action: string): Promise<void>;
1
3
  /**
2
4
  * Searches upwards from startPath to find a directory named .ruler.
3
5
  * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
4
6
  * Returns the path to the .ruler directory, or null if not found.
5
7
  */
6
8
  export declare function findRulerDir(startPath: string, checkGlobal?: boolean): Promise<string | null>;
9
+ export declare function resolveProjectRootForRulerDir(requestedProjectRoot: string, rulerDir: string): string;
7
10
  /**
8
11
  * Options for {@link readMarkdownFiles}.
9
12
  */
@@ -29,12 +32,12 @@ export declare function readMarkdownFiles(rulerDir: string, options?: ReadMarkdo
29
32
  /**
30
33
  * Writes content to filePath, creating parent directories if necessary.
31
34
  */
32
- export declare function writeGeneratedFile(filePath: string, content: string): Promise<void>;
35
+ export declare function writeGeneratedFile(filePath: string, content: string, containmentRoot?: string): Promise<void>;
33
36
  /**
34
37
  * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
35
38
  * Keeps an existing backup intact so repeated applies preserve the original file.
36
39
  */
37
- export declare function backupFile(filePath: string): Promise<void>;
40
+ export declare function backupFile(filePath: string, containmentRoot?: string): Promise<void>;
38
41
  /**
39
42
  * Ensures that the given directory exists by creating it recursively.
40
43
  */
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.assertNotSymbolicLink = assertNotSymbolicLink;
37
+ exports.assertManagedPathInsideRoot = assertManagedPathInsideRoot;
36
38
  exports.findRulerDir = findRulerDir;
39
+ exports.resolveProjectRootForRulerDir = resolveProjectRootForRulerDir;
37
40
  exports.readMarkdownFiles = readMarkdownFiles;
38
41
  exports.writeGeneratedFile = writeGeneratedFile;
39
42
  exports.backupFile = backupFile;
@@ -44,7 +47,9 @@ const fs_1 = require("fs");
44
47
  const path = __importStar(require("path"));
45
48
  const os = __importStar(require("os"));
46
49
  const constants_1 = require("../constants");
50
+ const path_utils_1 = require("./path-utils");
47
51
  const SUBAGENTS_DIR_NAME = path.basename(constants_1.RULER_SUBAGENTS_PATH);
52
+ const RULER_GENERATED_MARKER = '<!-- Generated by Ruler -->';
48
53
  const DEFAULT_NESTED_DISCOVERY_IGNORES = new Set([
49
54
  '__fixtures__',
50
55
  '__generated__',
@@ -66,6 +71,66 @@ function getXdgConfigDir() {
66
71
  function shouldSkipNestedDiscoveryDir(dirName) {
67
72
  return (dirName.startsWith('.') || DEFAULT_NESTED_DISCOVERY_IGNORES.has(dirName));
68
73
  }
74
+ async function isSymbolicLink(filePath) {
75
+ try {
76
+ return (await fs_1.promises.lstat(filePath)).isSymbolicLink();
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ async function assertNotSymbolicLink(filePath, action) {
83
+ if (await isSymbolicLink(filePath)) {
84
+ throw new Error(`${action}: ${filePath}`);
85
+ }
86
+ }
87
+ async function assertContainingDirectoryInsideRoot(filePath, rootPath, action) {
88
+ const realRoot = await fs_1.promises.realpath(rootPath);
89
+ let current = path.dirname(path.resolve(filePath));
90
+ while (true) {
91
+ try {
92
+ const realCurrent = await fs_1.promises.realpath(current);
93
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realCurrent)) {
94
+ throw new Error(`${action}: ${filePath}`);
95
+ }
96
+ return;
97
+ }
98
+ catch (error) {
99
+ if (error.code !== 'ENOENT') {
100
+ throw error;
101
+ }
102
+ }
103
+ const parent = path.dirname(current);
104
+ if (parent === current) {
105
+ return;
106
+ }
107
+ current = parent;
108
+ }
109
+ }
110
+ async function assertManagedPathInsideRoot(managedPath, rootPath, action) {
111
+ const realRoot = await fs_1.promises.realpath(rootPath);
112
+ await assertContainingDirectoryInsideRoot(managedPath, rootPath, action);
113
+ try {
114
+ const realManagedPath = await fs_1.promises.realpath(managedPath);
115
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realManagedPath)) {
116
+ throw new Error(`${action}: ${managedPath}`);
117
+ }
118
+ }
119
+ catch (error) {
120
+ if (error.code !== 'ENOENT') {
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+ async function isRulerGeneratedFile(filePath) {
126
+ try {
127
+ const content = await fs_1.promises.readFile(filePath, 'utf8');
128
+ return content.startsWith(RULER_GENERATED_MARKER);
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
69
134
  /**
70
135
  * Searches upwards from startPath to find a directory named .ruler.
71
136
  * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
@@ -101,11 +166,19 @@ async function findRulerDir(startPath, checkGlobal = true) {
101
166
  }
102
167
  }
103
168
  catch (err) {
169
+ if (err.code === 'ENOENT') {
170
+ return null;
171
+ }
104
172
  console.error(`[ruler] Error checking global config directory ${globalConfigDir}:`, err);
105
173
  }
106
174
  }
107
175
  return null;
108
176
  }
177
+ function resolveProjectRootForRulerDir(requestedProjectRoot, rulerDir) {
178
+ return path.basename(rulerDir) === '.ruler'
179
+ ? path.dirname(rulerDir)
180
+ : requestedProjectRoot;
181
+ }
109
182
  /**
110
183
  * Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
111
184
  * Files are sorted alphabetically by path.
@@ -116,6 +189,8 @@ async function findRulerDir(startPath, checkGlobal = true) {
116
189
  async function readMarkdownFiles(rulerDir, options = {}) {
117
190
  const mdFiles = [];
118
191
  const includeAgents = options.includeAgents === true;
192
+ const realRulerDir = await fs_1.promises.realpath(rulerDir);
193
+ const visitedDirectories = new Set();
119
194
  // Tracks whether we skipped a `.ruler/agents` subtree so the root-AGENTS.md
120
195
  // fallback below still recognises ruler content as present and does not
121
196
  // resurrect a previously generated root AGENTS.md (which may itself contain
@@ -123,6 +198,17 @@ async function readMarkdownFiles(rulerDir, options = {}) {
123
198
  let sawExcludedAgents = false;
124
199
  // Gather all markdown files (recursive) first
125
200
  async function walk(dir) {
201
+ let realDir;
202
+ try {
203
+ realDir = await fs_1.promises.realpath(dir);
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ if (visitedDirectories.has(realDir)) {
209
+ return;
210
+ }
211
+ visitedDirectories.add(realDir);
126
212
  const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
127
213
  for (const entry of entries) {
128
214
  const fullPath = path.join(dir, entry.name);
@@ -131,6 +217,10 @@ async function readMarkdownFiles(rulerDir, options = {}) {
131
217
  let isFile = entry.isFile();
132
218
  if (entry.isSymbolicLink()) {
133
219
  try {
220
+ const realTarget = await fs_1.promises.realpath(fullPath);
221
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRulerDir, realTarget)) {
222
+ continue;
223
+ }
134
224
  const stat = await fs_1.promises.stat(fullPath);
135
225
  isDir = stat.isDirectory();
136
226
  isFile = stat.isFile();
@@ -201,7 +291,19 @@ async function readMarkdownFiles(rulerDir, options = {}) {
201
291
  const repoRoot = path.dirname(rulerDir); // .ruler parent
202
292
  const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
203
293
  if (path.resolve(rootAgentsPath) !== path.resolve(topLevelAgents)) {
204
- const stat = await fs_1.promises.stat(rootAgentsPath);
294
+ const rootAgentsStat = await fs_1.promises.lstat(rootAgentsPath);
295
+ if (rootAgentsStat.isSymbolicLink()) {
296
+ const [realRepoRoot, realRootAgentsPath] = await Promise.all([
297
+ fs_1.promises.realpath(repoRoot),
298
+ fs_1.promises.realpath(rootAgentsPath),
299
+ ]);
300
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRepoRoot, realRootAgentsPath)) {
301
+ return ordered;
302
+ }
303
+ }
304
+ const stat = rootAgentsStat.isSymbolicLink()
305
+ ? await fs_1.promises.stat(rootAgentsPath)
306
+ : rootAgentsStat;
205
307
  if (stat.isFile()) {
206
308
  const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
207
309
  // Check if this is a generated file and we have other .ruler files.
@@ -231,16 +333,29 @@ async function readMarkdownFiles(rulerDir, options = {}) {
231
333
  /**
232
334
  * Writes content to filePath, creating parent directories if necessary.
233
335
  */
234
- async function writeGeneratedFile(filePath, content) {
336
+ async function writeGeneratedFile(filePath, content, containmentRoot) {
337
+ if (containmentRoot) {
338
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
339
+ }
235
340
  await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
341
+ await assertNotSymbolicLink(filePath, 'Refusing to write generated file through symlink');
342
+ if (containmentRoot) {
343
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
344
+ }
236
345
  await fs_1.promises.writeFile(filePath, content, 'utf8');
237
346
  }
238
347
  /**
239
348
  * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
240
349
  * Keeps an existing backup intact so repeated applies preserve the original file.
241
350
  */
242
- async function backupFile(filePath) {
351
+ async function backupFile(filePath, containmentRoot) {
243
352
  const backupPath = `${filePath}.bak`;
353
+ if (containmentRoot) {
354
+ await assertManagedPathInsideRoot(filePath, containmentRoot, 'Refusing to back up generated file through symlinked directory');
355
+ await assertManagedPathInsideRoot(backupPath, containmentRoot, 'Refusing to create backup file through symlinked directory');
356
+ }
357
+ await assertNotSymbolicLink(filePath, 'Refusing to back up symlinked file');
358
+ await assertNotSymbolicLink(backupPath, 'Refusing to use symlinked backup file');
244
359
  try {
245
360
  await fs_1.promises.access(backupPath);
246
361
  return;
@@ -248,6 +363,9 @@ async function backupFile(filePath) {
248
363
  catch {
249
364
  // continue if no backup exists yet
250
365
  }
366
+ if (await isRulerGeneratedFile(filePath)) {
367
+ return;
368
+ }
251
369
  try {
252
370
  await fs_1.promises.copyFile(filePath, backupPath);
253
371
  }
@@ -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
  */