@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.
- package/README.md +97 -10
- package/dist/agents/AbstractAgent.js +3 -2
- package/dist/agents/AgentsMdAgent.js +3 -2
- package/dist/agents/AiderAgent.js +4 -3
- package/dist/agents/AmazonQCliAgent.js +6 -4
- package/dist/agents/AugmentCodeAgent.js +3 -2
- package/dist/agents/CodexCliAgent.js +1 -1
- package/dist/agents/CrushAgent.d.ts +1 -1
- package/dist/agents/CrushAgent.js +15 -6
- package/dist/agents/FirebenderAgent.js +5 -4
- package/dist/agents/GeminiCliAgent.d.ts +1 -0
- package/dist/agents/GeminiCliAgent.js +11 -5
- package/dist/agents/IAgent.d.ts +2 -0
- package/dist/agents/MistralVibeAgent.js +14 -3
- package/dist/agents/OpenCodeAgent.d.ts +1 -1
- package/dist/agents/OpenCodeAgent.js +10 -3
- package/dist/agents/QwenCodeAgent.d.ts +1 -0
- package/dist/agents/QwenCodeAgent.js +9 -3
- package/dist/agents/RooCodeAgent.js +3 -2
- package/dist/agents/ZedAgent.js +3 -3
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/ConfigLoader.d.ts +2 -0
- package/dist/core/ConfigLoader.js +73 -6
- package/dist/core/FileSystemUtils.d.ts +4 -2
- package/dist/core/FileSystemUtils.js +120 -3
- package/dist/core/GitignoreUtils.d.ts +10 -0
- package/dist/core/GitignoreUtils.js +62 -31
- package/dist/core/SkillsProcessor.d.ts +2 -2
- package/dist/core/SkillsProcessor.js +46 -37
- package/dist/core/SubagentsProcessor.js +8 -5
- package/dist/core/UnifiedConfigLoader.js +54 -2
- package/dist/core/UnifiedConfigTypes.d.ts +3 -1
- package/dist/core/agent-selection.js +6 -4
- package/dist/core/apply-engine.d.ts +1 -0
- package/dist/core/apply-engine.js +38 -15
- package/dist/core/revert-engine.d.ts +2 -1
- package/dist/core/revert-engine.js +73 -26
- package/dist/lib.js +9 -6
- package/dist/mcp/merge.js +28 -26
- package/dist/mcp/propagateOpenCodeMcp.d.ts +1 -1
- package/dist/mcp/propagateOpenCodeMcp.js +10 -3
- package/dist/mcp/propagateOpenHandsMcp.d.ts +1 -1
- package/dist/mcp/propagateOpenHandsMcp.js +18 -7
- package/dist/paths/mcp.d.ts +1 -1
- package/dist/paths/mcp.js +11 -4
- package/dist/revert.js +27 -27
- package/dist/vscode/settings.d.ts +1 -1
- package/dist/vscode/settings.js +3 -3
- 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 = '.
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
let
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 (
|
|
178
|
-
|
|
179
|
-
processedFirstBlock = true;
|
|
180
|
-
newLines.push(line);
|
|
213
|
+
if (block) {
|
|
214
|
+
index = block.end;
|
|
181
215
|
continue;
|
|
182
216
|
}
|
|
183
|
-
|
|
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 (
|
|
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 .
|
|
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
|
*/
|