@intellectronica/ruler 0.3.41 → 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 +135 -36
- package/dist/agents/AbstractAgent.d.ts +53 -0
- package/dist/agents/AbstractAgent.js +3 -2
- package/dist/agents/AgentsMdAgent.d.ts +14 -0
- package/dist/agents/AgentsMdAgent.js +3 -2
- package/dist/agents/AiderAgent.d.ts +14 -0
- package/dist/agents/AiderAgent.js +7 -4
- package/dist/agents/AmazonQCliAgent.d.ts +13 -0
- package/dist/agents/AmazonQCliAgent.js +6 -4
- package/dist/agents/AmpAgent.d.ts +6 -0
- package/dist/agents/AntigravityAgent.d.ts +10 -0
- package/dist/agents/AugmentCodeAgent.d.ts +13 -0
- package/dist/agents/AugmentCodeAgent.js +3 -2
- package/dist/agents/ClaudeAgent.d.ts +13 -0
- package/dist/agents/ClineAgent.d.ts +9 -0
- package/dist/agents/CodexCliAgent.d.ts +31 -0
- package/dist/agents/CodexCliAgent.js +1 -1
- package/dist/agents/CopilotAgent.d.ts +20 -0
- package/dist/agents/CrushAgent.d.ts +14 -0
- package/dist/agents/CrushAgent.js +18 -6
- package/dist/agents/CursorAgent.d.ts +17 -0
- package/dist/agents/FactoryDroidAgent.d.ts +13 -0
- package/dist/agents/FirebaseAgent.d.ts +11 -0
- package/dist/agents/FirebenderAgent.d.ts +36 -0
- package/dist/agents/FirebenderAgent.js +5 -4
- package/dist/agents/GeminiCliAgent.d.ts +12 -0
- package/dist/agents/GeminiCliAgent.js +13 -7
- package/dist/agents/GooseAgent.d.ts +12 -0
- package/dist/agents/IAgent.d.ts +74 -0
- package/dist/agents/JetBrainsAiAssistantAgent.d.ts +10 -0
- package/dist/agents/JulesAgent.d.ts +5 -0
- package/dist/agents/JunieAgent.d.ts +12 -0
- package/dist/agents/KiloCodeAgent.d.ts +14 -0
- package/dist/agents/KiroAgent.d.ts +8 -0
- package/dist/agents/MistralVibeAgent.d.ts +31 -0
- package/dist/agents/MistralVibeAgent.js +14 -3
- package/dist/agents/OpenCodeAgent.d.ts +11 -0
- package/dist/agents/OpenCodeAgent.js +24 -12
- package/dist/agents/OpenHandsAgent.d.ts +8 -0
- package/dist/agents/PiAgent.d.ts +9 -0
- package/dist/agents/QwenCodeAgent.d.ts +11 -0
- package/dist/agents/QwenCodeAgent.js +11 -5
- package/dist/agents/RooCodeAgent.d.ts +16 -0
- package/dist/agents/RooCodeAgent.js +3 -2
- package/dist/agents/TraeAgent.d.ts +10 -0
- package/dist/agents/WarpAgent.d.ts +12 -0
- package/dist/agents/WindsurfAgent.d.ts +13 -0
- package/dist/agents/ZedAgent.d.ts +21 -0
- package/dist/agents/ZedAgent.js +8 -5
- package/dist/agents/agent-utils.d.ts +5 -0
- package/dist/agents/agent-utils.js +8 -5
- package/dist/agents/index.d.ts +9 -0
- package/dist/cli/commands.d.ts +4 -0
- package/dist/cli/commands.js +1 -2
- package/dist/cli/handlers.d.ts +41 -0
- package/dist/cli/handlers.js +75 -59
- package/dist/cli/index.d.ts +2 -0
- package/dist/constants.d.ts +35 -0
- package/dist/constants.js +1 -1
- package/dist/core/ConfigLoader.d.ts +59 -0
- package/dist/core/ConfigLoader.js +178 -44
- package/dist/core/FileSystemUtils.d.ts +53 -0
- package/dist/core/FileSystemUtils.js +157 -20
- package/dist/core/GitignoreUtils.d.ts +25 -0
- package/dist/core/GitignoreUtils.js +94 -32
- package/dist/core/RuleProcessor.d.ts +8 -0
- package/dist/core/SkillsProcessor.d.ts +127 -0
- package/dist/core/SkillsProcessor.js +118 -223
- package/dist/core/SkillsUtils.d.ts +26 -0
- package/dist/core/SubagentsProcessor.d.ts +38 -0
- package/dist/core/SubagentsProcessor.js +8 -5
- package/dist/core/SubagentsUtils.d.ts +34 -0
- package/dist/core/UnifiedConfigLoader.d.ts +10 -0
- package/dist/core/UnifiedConfigLoader.js +115 -33
- package/dist/core/UnifiedConfigTypes.d.ts +97 -0
- package/dist/core/agent-selection.d.ts +12 -0
- package/dist/core/agent-selection.js +17 -7
- package/dist/core/apply-engine.d.ts +70 -0
- package/dist/core/apply-engine.js +88 -58
- package/dist/core/config-utils.d.ts +14 -0
- package/dist/core/config-utils.js +9 -3
- package/dist/core/hash.d.ts +2 -0
- package/dist/core/path-utils.d.ts +1 -0
- package/dist/core/path-utils.js +42 -0
- package/dist/core/revert-engine.d.ts +37 -0
- package/dist/core/revert-engine.js +142 -34
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +24 -8
- package/dist/mcp/capabilities.d.ts +20 -0
- package/dist/mcp/merge.d.ts +10 -0
- package/dist/mcp/merge.js +36 -16
- package/dist/mcp/propagateOpenCodeMcp.d.ts +2 -0
- package/dist/mcp/propagateOpenCodeMcp.js +30 -11
- package/dist/mcp/propagateOpenHandsMcp.d.ts +2 -0
- package/dist/mcp/propagateOpenHandsMcp.js +48 -21
- package/dist/mcp/validate.d.ts +7 -0
- package/dist/mcp/validate.js +6 -1
- package/dist/paths/mcp.d.ts +8 -0
- package/dist/paths/mcp.js +44 -8
- package/dist/revert.d.ts +6 -0
- package/dist/revert.js +58 -46
- package/dist/types.d.ts +87 -0
- package/dist/vscode/settings.d.ts +40 -0
- package/dist/vscode/settings.js +3 -3
- package/package.json +8 -5
|
@@ -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,17 +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()
|
|
117
|
+
.optional(),
|
|
118
|
+
backup: zod_1.z
|
|
119
|
+
.object({
|
|
120
|
+
enabled: zod_1.z.boolean().optional(),
|
|
121
|
+
})
|
|
122
|
+
.strict()
|
|
105
123
|
.optional(),
|
|
106
124
|
skills: zod_1.z
|
|
107
125
|
.object({
|
|
108
126
|
enabled: zod_1.z.boolean().optional(),
|
|
109
127
|
})
|
|
128
|
+
.strict()
|
|
110
129
|
.optional(),
|
|
111
130
|
// Deprecated: kept in the schema only so that legacy `[subagents]` blocks
|
|
112
131
|
// are preserved through validation. The parser reads from here as a
|
|
@@ -118,9 +137,11 @@ const rulerConfigSchema = zod_1.z.object({
|
|
|
118
137
|
include_in_rules: zod_1.z.boolean().optional(),
|
|
119
138
|
cleanup_orphaned: zod_1.z.boolean().optional(),
|
|
120
139
|
})
|
|
140
|
+
.strict()
|
|
121
141
|
.optional(),
|
|
122
142
|
nested: zod_1.z.boolean().optional(),
|
|
123
|
-
})
|
|
143
|
+
})
|
|
144
|
+
.strict();
|
|
124
145
|
/**
|
|
125
146
|
* Recursively creates a new object with only enumerable string keys,
|
|
126
147
|
* effectively excluding Symbol properties.
|
|
@@ -142,50 +163,44 @@ function stripSymbols(obj) {
|
|
|
142
163
|
}
|
|
143
164
|
return result;
|
|
144
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
|
+
}
|
|
145
192
|
/**
|
|
146
193
|
* Loads and parses the ruler TOML configuration file, applying defaults.
|
|
147
|
-
*
|
|
194
|
+
* Missing implicit configs return defaults. Explicit configs and existing
|
|
195
|
+
* implicit configs fail fast when missing, unreadable, or invalid.
|
|
148
196
|
*/
|
|
149
197
|
async function loadConfig(options) {
|
|
150
198
|
const { projectRoot, configPath, cliAgents } = options;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Try local .ruler/ruler.toml first
|
|
157
|
-
const localConfigFile = path.join(projectRoot, '.ruler', 'ruler.toml');
|
|
158
|
-
try {
|
|
159
|
-
await fs_1.promises.access(localConfigFile);
|
|
160
|
-
configFile = localConfigFile;
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
// If local config doesn't exist, try global config
|
|
164
|
-
const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
165
|
-
configFile = path.join(xdgConfigDir, 'ruler', 'ruler.toml');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
let raw = {};
|
|
169
|
-
try {
|
|
170
|
-
const text = await fs_1.promises.readFile(configFile, 'utf8');
|
|
171
|
-
const parsed = text.trim() ? (0, toml_1.parse)(text) : {};
|
|
172
|
-
// Strip Symbol properties added by @iarna/toml (required for Zod v4+)
|
|
173
|
-
raw = stripSymbols(parsed);
|
|
174
|
-
// Validate the configuration with zod
|
|
175
|
-
const validationResult = rulerConfigSchema.safeParse(raw);
|
|
176
|
-
if (!validationResult.success) {
|
|
177
|
-
throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ')}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
catch (err) {
|
|
181
|
-
if (err instanceof Error && err.code !== 'ENOENT') {
|
|
182
|
-
if (err.message.includes('[ruler]')) {
|
|
183
|
-
throw err; // Re-throw validation errors
|
|
184
|
-
}
|
|
185
|
-
console.warn(`[ruler] Warning: could not read config file at ${configFile}: ${err.message}`);
|
|
186
|
-
}
|
|
187
|
-
raw = {};
|
|
188
|
-
}
|
|
199
|
+
const checkGlobal = options.checkGlobal ?? true;
|
|
200
|
+
const configFile = configPath
|
|
201
|
+
? path.resolve(configPath)
|
|
202
|
+
: await resolveImplicitConfigFile(projectRoot, checkGlobal);
|
|
203
|
+
const raw = configFile ? await readConfigFile(configFile) : {};
|
|
189
204
|
const defaultAgents = Array.isArray(raw.default_agents)
|
|
190
205
|
? raw.default_agents.map((a) => String(a))
|
|
191
206
|
: undefined;
|
|
@@ -206,13 +221,13 @@ async function loadConfig(options) {
|
|
|
206
221
|
cfg.enabled = sectionObj.enabled;
|
|
207
222
|
}
|
|
208
223
|
if (typeof sectionObj.output_path === 'string') {
|
|
209
|
-
cfg.outputPath =
|
|
224
|
+
cfg.outputPath = resolveProjectOutputPath(projectRoot, sectionObj.output_path, configFile, `[agents.${name}].output_path`);
|
|
210
225
|
}
|
|
211
226
|
if (typeof sectionObj.output_path_instructions === 'string') {
|
|
212
|
-
cfg.outputPathInstructions =
|
|
227
|
+
cfg.outputPathInstructions = resolveProjectOutputPath(projectRoot, sectionObj.output_path_instructions, configFile, `[agents.${name}].output_path_instructions`);
|
|
213
228
|
}
|
|
214
229
|
if (typeof sectionObj.output_path_config === 'string') {
|
|
215
|
-
cfg.outputPathConfig =
|
|
230
|
+
cfg.outputPathConfig = resolveProjectOutputPath(projectRoot, sectionObj.output_path_config, configFile, `[agents.${name}].output_path_config`);
|
|
216
231
|
}
|
|
217
232
|
if (sectionObj.mcp && typeof sectionObj.mcp === 'object') {
|
|
218
233
|
const m = sectionObj.mcp;
|
|
@@ -228,6 +243,7 @@ async function loadConfig(options) {
|
|
|
228
243
|
}
|
|
229
244
|
cfg.mcp = mcpCfg;
|
|
230
245
|
}
|
|
246
|
+
cfg.mcpServers = parseAgentMcpServers(sectionObj);
|
|
231
247
|
agentConfigs[name] = cfg;
|
|
232
248
|
}
|
|
233
249
|
}
|
|
@@ -256,6 +272,13 @@ async function loadConfig(options) {
|
|
|
256
272
|
if (typeof rawGitignoreSection.local === 'boolean') {
|
|
257
273
|
gitignoreConfig.local = rawGitignoreSection.local;
|
|
258
274
|
}
|
|
275
|
+
const rawBackupSection = raw.backup && typeof raw.backup === 'object' && !Array.isArray(raw.backup)
|
|
276
|
+
? raw.backup
|
|
277
|
+
: {};
|
|
278
|
+
const backupConfig = {};
|
|
279
|
+
if (typeof rawBackupSection.enabled === 'boolean') {
|
|
280
|
+
backupConfig.enabled = rawBackupSection.enabled;
|
|
281
|
+
}
|
|
259
282
|
const rawSkillsSection = raw.skills && typeof raw.skills === 'object' && !Array.isArray(raw.skills)
|
|
260
283
|
? raw.skills
|
|
261
284
|
: {};
|
|
@@ -314,9 +337,120 @@ async function loadConfig(options) {
|
|
|
314
337
|
cliAgents,
|
|
315
338
|
mcp: globalMcpConfig,
|
|
316
339
|
gitignore: gitignoreConfig,
|
|
340
|
+
backup: backupConfig,
|
|
317
341
|
skills: skillsConfig,
|
|
318
342
|
subagents: subagentsConfig,
|
|
319
343
|
nested,
|
|
320
344
|
nestedDefined,
|
|
321
345
|
};
|
|
322
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
|
+
}
|
|
361
|
+
async function resolveImplicitConfigFile(projectRoot, checkGlobal) {
|
|
362
|
+
const localRulerDir = await findNearestLocalRulerDir(projectRoot);
|
|
363
|
+
const localConfigFile = localRulerDir
|
|
364
|
+
? path.join(localRulerDir, 'ruler.toml')
|
|
365
|
+
: path.join(projectRoot, '.ruler', 'ruler.toml');
|
|
366
|
+
if (await configFileExists(localConfigFile)) {
|
|
367
|
+
return localConfigFile;
|
|
368
|
+
}
|
|
369
|
+
if (!checkGlobal) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
373
|
+
const globalConfigFile = path.join(xdgConfigDir, 'ruler', 'ruler.toml');
|
|
374
|
+
if (await configFileExists(globalConfigFile)) {
|
|
375
|
+
return globalConfigFile;
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
async function findNearestLocalRulerDir(startPath) {
|
|
380
|
+
let current = path.resolve(startPath);
|
|
381
|
+
while (current) {
|
|
382
|
+
const candidate = path.join(current, '.ruler');
|
|
383
|
+
try {
|
|
384
|
+
const stat = await fs_1.promises.stat(candidate);
|
|
385
|
+
if (stat.isDirectory()) {
|
|
386
|
+
return candidate;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Keep walking; missing or inaccessible candidates simply do not match.
|
|
391
|
+
}
|
|
392
|
+
const parent = path.dirname(current);
|
|
393
|
+
if (parent === current) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
current = parent;
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
async function configFileExists(configFile) {
|
|
401
|
+
try {
|
|
402
|
+
await fs_1.promises.access(configFile);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
if (err.code === 'ENOENT') {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
throw (0, constants_1.createRulerError)('Could not access configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function readConfigFile(configFile) {
|
|
413
|
+
const text = await readConfigText(configFile);
|
|
414
|
+
const parsed = parseConfigText(text, configFile);
|
|
415
|
+
const raw = stripSymbols(parsed);
|
|
416
|
+
validateConfig(raw, configFile);
|
|
417
|
+
return raw;
|
|
418
|
+
}
|
|
419
|
+
async function readConfigText(configFile) {
|
|
420
|
+
try {
|
|
421
|
+
return await fs_1.promises.readFile(configFile, 'utf8');
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
if (err.code === 'ENOENT') {
|
|
425
|
+
throw (0, constants_1.createRulerError)('Configuration file not found', `File: ${configFile}`);
|
|
426
|
+
}
|
|
427
|
+
throw (0, constants_1.createRulerError)('Could not read configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function parseConfigText(text, configFile) {
|
|
431
|
+
try {
|
|
432
|
+
return text.trim() ? (0, toml_1.parse)(text) : {};
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
throw (0, constants_1.createRulerError)('Invalid configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function validateConfig(raw, configFile) {
|
|
439
|
+
const validationResult = rulerConfigSchema.safeParse(raw);
|
|
440
|
+
if (!validationResult.success) {
|
|
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(', ');
|
|
451
|
+
}
|
|
452
|
+
return `${basePath}: ${issue.message}`;
|
|
453
|
+
}
|
|
454
|
+
function errorMessage(err) {
|
|
455
|
+
return err instanceof Error ? err.message : String(err);
|
|
456
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare function assertManagedPathInsideRoot(managedPath: string, rootPath: string, action: string): Promise<void>;
|
|
2
|
+
/**
|
|
3
|
+
* Searches upwards from startPath to find a directory named .ruler.
|
|
4
|
+
* If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
|
|
5
|
+
* Returns the path to the .ruler directory, or null if not found.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findRulerDir(startPath: string, checkGlobal?: boolean): Promise<string | null>;
|
|
8
|
+
export declare function resolveProjectRootForRulerDir(requestedProjectRoot: string, rulerDir: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Options for {@link readMarkdownFiles}.
|
|
11
|
+
*/
|
|
12
|
+
export interface ReadMarkdownFilesOptions {
|
|
13
|
+
/**
|
|
14
|
+
* When true, include `.ruler/agents/*.md` in the returned set so they are
|
|
15
|
+
* concatenated into the top-level generated rule files. When false or
|
|
16
|
+
* omitted, `.ruler/agents/` is skipped, mirroring `.ruler/skills/`.
|
|
17
|
+
*/
|
|
18
|
+
includeAgents?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
|
|
22
|
+
* Files are sorted alphabetically by path.
|
|
23
|
+
*
|
|
24
|
+
* `.ruler/skills/` is always skipped (skills are propagated separately).
|
|
25
|
+
* `.ruler/agents/` is skipped unless `options.includeAgents` is `true`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readMarkdownFiles(rulerDir: string, options?: ReadMarkdownFilesOptions): Promise<{
|
|
28
|
+
path: string;
|
|
29
|
+
content: string;
|
|
30
|
+
}[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Writes content to filePath, creating parent directories if necessary.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeGeneratedFile(filePath: string, content: string, containmentRoot?: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Creates a backup of the given filePath by copying it to filePath.bak if it exists.
|
|
37
|
+
* Keeps an existing backup intact so repeated applies preserve the original file.
|
|
38
|
+
*/
|
|
39
|
+
export declare function backupFile(filePath: string, containmentRoot?: string): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Ensures that the given directory exists by creating it recursively.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ensureDirExists(dirPath: string): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Finds the global ruler configuration directory at XDG_CONFIG_HOME/ruler.
|
|
46
|
+
* Returns the path if it exists, null otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export declare function findGlobalRulerDir(): Promise<string | null>;
|
|
49
|
+
/**
|
|
50
|
+
* Searches the entire directory tree from startPath to find all .ruler directories.
|
|
51
|
+
* Returns an array of .ruler directory paths from most specific to least specific.
|
|
52
|
+
*/
|
|
53
|
+
export declare function findAllRulerDirs(startPath: string): Promise<string[]>;
|
|
@@ -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,13 +46,90 @@ 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 -->';
|
|
52
|
+
const DEFAULT_NESTED_DISCOVERY_IGNORES = new Set([
|
|
53
|
+
'__fixtures__',
|
|
54
|
+
'__generated__',
|
|
55
|
+
'build',
|
|
56
|
+
'coverage',
|
|
57
|
+
'dist',
|
|
58
|
+
'fixtures',
|
|
59
|
+
'generated',
|
|
60
|
+
'node_modules',
|
|
61
|
+
'temp',
|
|
62
|
+
'tmp',
|
|
63
|
+
]);
|
|
48
64
|
/**
|
|
49
65
|
* Gets the XDG config directory path, falling back to ~/.config if XDG_CONFIG_HOME is not set.
|
|
50
66
|
*/
|
|
51
67
|
function getXdgConfigDir() {
|
|
52
68
|
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
53
69
|
}
|
|
70
|
+
function shouldSkipNestedDiscoveryDir(dirName) {
|
|
71
|
+
return (dirName.startsWith('.') || DEFAULT_NESTED_DISCOVERY_IGNORES.has(dirName));
|
|
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
|
+
}
|
|
54
133
|
/**
|
|
55
134
|
* Searches upwards from startPath to find a directory named .ruler.
|
|
56
135
|
* If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
|
|
@@ -86,11 +165,19 @@ async function findRulerDir(startPath, checkGlobal = true) {
|
|
|
86
165
|
}
|
|
87
166
|
}
|
|
88
167
|
catch (err) {
|
|
168
|
+
if (err.code === 'ENOENT') {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
89
171
|
console.error(`[ruler] Error checking global config directory ${globalConfigDir}:`, err);
|
|
90
172
|
}
|
|
91
173
|
}
|
|
92
174
|
return null;
|
|
93
175
|
}
|
|
176
|
+
function resolveProjectRootForRulerDir(requestedProjectRoot, rulerDir) {
|
|
177
|
+
return path.basename(rulerDir) === '.ruler'
|
|
178
|
+
? path.dirname(rulerDir)
|
|
179
|
+
: requestedProjectRoot;
|
|
180
|
+
}
|
|
94
181
|
/**
|
|
95
182
|
* Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
|
|
96
183
|
* Files are sorted alphabetically by path.
|
|
@@ -101,6 +188,8 @@ async function findRulerDir(startPath, checkGlobal = true) {
|
|
|
101
188
|
async function readMarkdownFiles(rulerDir, options = {}) {
|
|
102
189
|
const mdFiles = [];
|
|
103
190
|
const includeAgents = options.includeAgents === true;
|
|
191
|
+
const realRulerDir = await fs_1.promises.realpath(rulerDir);
|
|
192
|
+
const visitedDirectories = new Set();
|
|
104
193
|
// Tracks whether we skipped a `.ruler/agents` subtree so the root-AGENTS.md
|
|
105
194
|
// fallback below still recognises ruler content as present and does not
|
|
106
195
|
// resurrect a previously generated root AGENTS.md (which may itself contain
|
|
@@ -108,6 +197,17 @@ async function readMarkdownFiles(rulerDir, options = {}) {
|
|
|
108
197
|
let sawExcludedAgents = false;
|
|
109
198
|
// Gather all markdown files (recursive) first
|
|
110
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);
|
|
111
211
|
const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
|
|
112
212
|
for (const entry of entries) {
|
|
113
213
|
const fullPath = path.join(dir, entry.name);
|
|
@@ -116,6 +216,10 @@ async function readMarkdownFiles(rulerDir, options = {}) {
|
|
|
116
216
|
let isFile = entry.isFile();
|
|
117
217
|
if (entry.isSymbolicLink()) {
|
|
118
218
|
try {
|
|
219
|
+
const realTarget = await fs_1.promises.realpath(fullPath);
|
|
220
|
+
if (!(0, path_utils_1.isPathInsideOrEqual)(realRulerDir, realTarget)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
119
223
|
const stat = await fs_1.promises.stat(fullPath);
|
|
120
224
|
isDir = stat.isDirectory();
|
|
121
225
|
isFile = stat.isFile();
|
|
@@ -186,7 +290,19 @@ async function readMarkdownFiles(rulerDir, options = {}) {
|
|
|
186
290
|
const repoRoot = path.dirname(rulerDir); // .ruler parent
|
|
187
291
|
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
|
|
188
292
|
if (path.resolve(rootAgentsPath) !== path.resolve(topLevelAgents)) {
|
|
189
|
-
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;
|
|
190
306
|
if (stat.isFile()) {
|
|
191
307
|
const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
|
|
192
308
|
// Check if this is a generated file and we have other .ruler files.
|
|
@@ -216,17 +332,41 @@ async function readMarkdownFiles(rulerDir, options = {}) {
|
|
|
216
332
|
/**
|
|
217
333
|
* Writes content to filePath, creating parent directories if necessary.
|
|
218
334
|
*/
|
|
219
|
-
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
|
+
}
|
|
220
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
|
+
}
|
|
221
344
|
await fs_1.promises.writeFile(filePath, content, 'utf8');
|
|
222
345
|
}
|
|
223
346
|
/**
|
|
224
347
|
* Creates a backup of the given filePath by copying it to filePath.bak if it exists.
|
|
348
|
+
* Keeps an existing backup intact so repeated applies preserve the original file.
|
|
225
349
|
*/
|
|
226
|
-
async function backupFile(filePath) {
|
|
350
|
+
async function backupFile(filePath, containmentRoot) {
|
|
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');
|
|
358
|
+
try {
|
|
359
|
+
await fs_1.promises.access(backupPath);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// continue if no backup exists yet
|
|
364
|
+
}
|
|
365
|
+
if (await isRulerGeneratedFile(filePath)) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
227
368
|
try {
|
|
228
|
-
await fs_1.promises.
|
|
229
|
-
await fs_1.promises.copyFile(filePath, `${filePath}.bak`);
|
|
369
|
+
await fs_1.promises.copyFile(filePath, backupPath);
|
|
230
370
|
}
|
|
231
371
|
catch {
|
|
232
372
|
// ignore if file does not exist
|
|
@@ -272,23 +412,20 @@ async function findAllRulerDirs(startPath) {
|
|
|
272
412
|
if (entry.name === '.ruler') {
|
|
273
413
|
rulerDirs.push(fullPath);
|
|
274
414
|
}
|
|
275
|
-
else {
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
path.resolve(fullPath) !== rootPath) {
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
// no .git boundary, continue traversal
|
|
415
|
+
else if (!shouldSkipNestedDiscoveryDir(entry.name)) {
|
|
416
|
+
// Do not cross git repository boundaries (except the starting root)
|
|
417
|
+
const gitDir = path.join(fullPath, '.git');
|
|
418
|
+
try {
|
|
419
|
+
const gitStat = await fs_1.promises.stat(gitDir);
|
|
420
|
+
if (gitStat.isDirectory() &&
|
|
421
|
+
path.resolve(fullPath) !== rootPath) {
|
|
422
|
+
continue;
|
|
289
423
|
}
|
|
290
|
-
await findRulerDirs(fullPath);
|
|
291
424
|
}
|
|
425
|
+
catch {
|
|
426
|
+
// no .git boundary, continue traversal
|
|
427
|
+
}
|
|
428
|
+
await findRulerDirs(fullPath);
|
|
292
429
|
}
|
|
293
430
|
}
|
|
294
431
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface RulerBlockRange {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
}
|
|
5
|
+
export interface RemoveRulerBlocksResult {
|
|
6
|
+
content: string;
|
|
7
|
+
removed: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Updates an ignore file in the project root with paths in a managed Ruler block.
|
|
11
|
+
* Creates the file if it doesn't exist, and creates or updates the Ruler-managed block.
|
|
12
|
+
*
|
|
13
|
+
* @param projectRoot The project root directory
|
|
14
|
+
* @param paths Array of file paths to add to the ignore file (can be absolute or relative)
|
|
15
|
+
* @param ignoreFile Relative path to the ignore file from project root (defaults to .gitignore)
|
|
16
|
+
*/
|
|
17
|
+
export declare function updateGitignore(projectRoot: string, paths: string[], ignoreFile?: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves ignore files Ruler manages. Linked worktrees store `.git` as a
|
|
20
|
+
* file containing a `gitdir:` pointer, so `.git/info/exclude` must be resolved
|
|
21
|
+
* through that pointer.
|
|
22
|
+
*/
|
|
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;
|