skiller 0.4.3

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +989 -0
  3. package/dist/agents/AbstractAgent.js +92 -0
  4. package/dist/agents/AgentsMdAgent.js +85 -0
  5. package/dist/agents/AiderAgent.js +108 -0
  6. package/dist/agents/AmazonQCliAgent.js +103 -0
  7. package/dist/agents/AmpAgent.js +13 -0
  8. package/dist/agents/AugmentCodeAgent.js +70 -0
  9. package/dist/agents/ClaudeAgent.js +95 -0
  10. package/dist/agents/ClineAgent.js +53 -0
  11. package/dist/agents/CodexCliAgent.js +143 -0
  12. package/dist/agents/CopilotAgent.js +43 -0
  13. package/dist/agents/CrushAgent.js +128 -0
  14. package/dist/agents/CursorAgent.js +93 -0
  15. package/dist/agents/FirebaseAgent.js +61 -0
  16. package/dist/agents/FirebenderAgent.js +205 -0
  17. package/dist/agents/GeminiCliAgent.js +99 -0
  18. package/dist/agents/GooseAgent.js +58 -0
  19. package/dist/agents/IAgent.js +2 -0
  20. package/dist/agents/JulesAgent.js +14 -0
  21. package/dist/agents/JunieAgent.js +53 -0
  22. package/dist/agents/KiloCodeAgent.js +63 -0
  23. package/dist/agents/KiroAgent.js +50 -0
  24. package/dist/agents/OpenCodeAgent.js +99 -0
  25. package/dist/agents/OpenHandsAgent.js +56 -0
  26. package/dist/agents/QwenCodeAgent.js +82 -0
  27. package/dist/agents/RooCodeAgent.js +139 -0
  28. package/dist/agents/TraeAgent.js +54 -0
  29. package/dist/agents/WarpAgent.js +61 -0
  30. package/dist/agents/WindsurfAgent.js +27 -0
  31. package/dist/agents/ZedAgent.js +132 -0
  32. package/dist/agents/agent-utils.js +37 -0
  33. package/dist/agents/index.js +77 -0
  34. package/dist/cli/commands.js +136 -0
  35. package/dist/cli/handlers.js +221 -0
  36. package/dist/cli/index.js +5 -0
  37. package/dist/constants.js +58 -0
  38. package/dist/core/ConfigLoader.js +274 -0
  39. package/dist/core/FileSystemUtils.js +421 -0
  40. package/dist/core/FrontmatterParser.js +142 -0
  41. package/dist/core/GitignoreUtils.js +171 -0
  42. package/dist/core/RuleProcessor.js +60 -0
  43. package/dist/core/SkillsProcessor.js +528 -0
  44. package/dist/core/SkillsUtils.js +230 -0
  45. package/dist/core/UnifiedConfigLoader.js +432 -0
  46. package/dist/core/UnifiedConfigTypes.js +2 -0
  47. package/dist/core/agent-selection.js +52 -0
  48. package/dist/core/apply-engine.js +668 -0
  49. package/dist/core/config-utils.js +30 -0
  50. package/dist/core/hash.js +24 -0
  51. package/dist/core/revert-engine.js +413 -0
  52. package/dist/lib.js +196 -0
  53. package/dist/mcp/capabilities.js +65 -0
  54. package/dist/mcp/merge.js +39 -0
  55. package/dist/mcp/propagateOpenCodeMcp.js +116 -0
  56. package/dist/mcp/propagateOpenHandsMcp.js +169 -0
  57. package/dist/mcp/validate.js +17 -0
  58. package/dist/paths/mcp.js +120 -0
  59. package/dist/revert.js +186 -0
  60. package/dist/types.js +2 -0
  61. package/dist/vscode/settings.js +117 -0
  62. package/package.json +77 -0
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.findRulerDir = findRulerDir;
37
+ exports.normalizePattern = normalizePattern;
38
+ exports.matchesPattern = matchesPattern;
39
+ exports.readMarkdownFiles = readMarkdownFiles;
40
+ exports.writeGeneratedFile = writeGeneratedFile;
41
+ exports.backupFile = backupFile;
42
+ exports.ensureDirExists = ensureDirExists;
43
+ exports.findGlobalRulerDir = findGlobalRulerDir;
44
+ exports.findAllRulerDirs = findAllRulerDirs;
45
+ const fs_1 = require("fs");
46
+ const path = __importStar(require("path"));
47
+ const os = __importStar(require("os"));
48
+ const FrontmatterParser_1 = require("./FrontmatterParser");
49
+ /**
50
+ * Gets the XDG config directory path, falling back to ~/.config if XDG_CONFIG_HOME is not set.
51
+ */
52
+ function getXdgConfigDir() {
53
+ return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
54
+ }
55
+ /**
56
+ * Searches upwards from startPath to find a directory named .ruler or .claude.
57
+ * Priority: .ruler first, then .claude (to check .ruler/ruler.toml before .claude/ruler.toml)
58
+ * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
59
+ * Returns the path to the found directory, or null if not found.
60
+ */
61
+ async function findRulerDir(startPath, checkGlobal = true) {
62
+ // First, search upwards from startPath for local .ruler directory
63
+ let current = startPath;
64
+ while (current) {
65
+ const rulerCandidate = path.join(current, '.ruler');
66
+ try {
67
+ const stat = await fs_1.promises.stat(rulerCandidate);
68
+ if (stat.isDirectory()) {
69
+ return rulerCandidate;
70
+ }
71
+ }
72
+ catch {
73
+ // ignore errors when checking for .ruler directory
74
+ }
75
+ const parent = path.dirname(current);
76
+ if (parent === current) {
77
+ break;
78
+ }
79
+ current = parent;
80
+ }
81
+ // If no .ruler found, search for .claude directory
82
+ current = startPath;
83
+ while (current) {
84
+ const claudeCandidate = path.join(current, '.claude');
85
+ try {
86
+ const stat = await fs_1.promises.stat(claudeCandidate);
87
+ if (stat.isDirectory()) {
88
+ // Check if this .claude directory has ruler.toml
89
+ const tomlPath = path.join(claudeCandidate, 'ruler.toml');
90
+ try {
91
+ await fs_1.promises.stat(tomlPath);
92
+ return claudeCandidate;
93
+ }
94
+ catch {
95
+ // .claude exists but no ruler.toml, continue searching
96
+ }
97
+ }
98
+ }
99
+ catch {
100
+ // ignore errors when checking for .claude directory
101
+ }
102
+ const parent = path.dirname(current);
103
+ if (parent === current) {
104
+ break;
105
+ }
106
+ current = parent;
107
+ }
108
+ // If no local .ruler or .claude found and checkGlobal is true, check global config directory
109
+ if (checkGlobal) {
110
+ const globalConfigDir = path.join(getXdgConfigDir(), 'ruler');
111
+ try {
112
+ const stat = await fs_1.promises.stat(globalConfigDir);
113
+ if (stat.isDirectory()) {
114
+ return globalConfigDir;
115
+ }
116
+ }
117
+ catch (err) {
118
+ console.error(`[ruler] Error checking global config directory ${globalConfigDir}:`, err);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ /**
124
+ * Normalizes a pattern by expanding directory patterns to glob patterns.
125
+ * Directory patterns (no wildcards, no .md/.mdc extension) are expanded to match all markdown files.
126
+ * @example "rules-global" becomes a pattern matching both .md and .mdc files
127
+ * @example "rules-global/star.md" stays unchanged
128
+ * @example "AGENTS.md" stays unchanged
129
+ */
130
+ function normalizePattern(pattern) {
131
+ // If pattern already contains wildcards or is a specific markdown file, return as-is
132
+ if (pattern.includes('*') ||
133
+ pattern.endsWith('.md') ||
134
+ pattern.endsWith('.mdc')) {
135
+ return pattern;
136
+ }
137
+ // Otherwise, treat as directory and expand to include all .md/.mdc files recursively
138
+ // We'll return the .md pattern, but the file walker will pick up both .md and .mdc
139
+ return `${pattern}/**/*.{md,mdc}`;
140
+ }
141
+ /**
142
+ * Simple glob pattern matcher supporting basic patterns:
143
+ * - `*` matches any characters except `/`
144
+ * - `**` matches any characters including `/` (zero or more path segments)
145
+ * - `{md,mdc}` matches either md or mdc
146
+ * - Exact string matches
147
+ */
148
+ function matchesPattern(filePath, pattern) {
149
+ // Handle brace expansion {md,mdc} -> (md|mdc)
150
+ let expandedPattern = pattern;
151
+ const braceMatch = pattern.match(/\{([^}]+)\}/);
152
+ if (braceMatch) {
153
+ const options = braceMatch[1].split(',');
154
+ expandedPattern = pattern.replace(braceMatch[0], `(${options.join('|')})`);
155
+ }
156
+ // Convert glob pattern to regex
157
+ // Escape special regex characters except * / ( ) |
158
+ let regexPattern = expandedPattern
159
+ .replace(/[.+?^${}[\]\\]/g, '\\$&')
160
+ // Replace **/ with a special placeholder (matches zero or more path segments)
161
+ .replace(/\*\*\//g, '__DOUBLESTAR_SLASH__')
162
+ // Replace /** with another placeholder
163
+ .replace(/\/\*\*/g, '__SLASH_DOUBLESTAR__')
164
+ // Replace remaining ** with yet another placeholder
165
+ .replace(/\*\*/g, '__DOUBLESTAR__')
166
+ // Replace single * with regex (matches anything except /)
167
+ .replace(/\*/g, '[^/]*')
168
+ // Replace **/ with regex that matches zero or more path segments
169
+ .replace(/__DOUBLESTAR_SLASH__/g, '(?:.*?/)?')
170
+ // Replace /** with regex
171
+ .replace(/__SLASH_DOUBLESTAR__/g, '(?:/.*)?')
172
+ // Replace standalone ** with regex
173
+ .replace(/__DOUBLESTAR__/g, '.*');
174
+ // Anchor the pattern to match the full path
175
+ regexPattern = '^' + regexPattern + '$';
176
+ const regex = new RegExp(regexPattern);
177
+ return regex.test(filePath);
178
+ }
179
+ /**
180
+ * Recursively reads all Markdown (.md and .mdc) files in rulerDir, returning their paths and contents.
181
+ * Files are sorted alphabetically by path.
182
+ *
183
+ * @param rulerDir The directory to scan for markdown files
184
+ * @param options Optional filtering configuration
185
+ * @param options.include Glob patterns to include (if specified, only matching files are included)
186
+ * @param options.exclude Glob patterns to exclude (takes precedence over include)
187
+ * @param options.merge_strategy Merge strategy: 'all' (default) or 'cursor' (uses MDC frontmatter)
188
+ */
189
+ async function readMarkdownFiles(rulerDir, options) {
190
+ const mdFiles = [];
191
+ // Gather all markdown files (recursive) first
192
+ async function walk(dir) {
193
+ const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
194
+ for (const entry of entries) {
195
+ const fullPath = path.join(dir, entry.name);
196
+ if (entry.isDirectory()) {
197
+ await walk(fullPath);
198
+ }
199
+ else if (entry.isFile() &&
200
+ (entry.name.endsWith('.md') || entry.name.endsWith('.mdc'))) {
201
+ const content = await fs_1.promises.readFile(fullPath, 'utf8');
202
+ mdFiles.push({ path: fullPath, content });
203
+ }
204
+ }
205
+ }
206
+ await walk(rulerDir);
207
+ // Apply include/exclude filters
208
+ let filteredFiles = mdFiles;
209
+ if (options?.include || options?.exclude) {
210
+ // Normalize patterns (expand directory patterns to globs)
211
+ const normalizedInclude = options.include?.map(normalizePattern);
212
+ const normalizedExclude = options.exclude?.map(normalizePattern);
213
+ filteredFiles = mdFiles.filter((file) => {
214
+ // Get relative path from rulerDir for pattern matching
215
+ const relativePath = path.relative(rulerDir, file.path);
216
+ // Normalize to forward slashes for consistent pattern matching
217
+ const normalizedPath = relativePath.replace(/\\/g, '/');
218
+ // Check exclude patterns first (they take precedence)
219
+ if (normalizedExclude) {
220
+ for (const pattern of normalizedExclude) {
221
+ if (matchesPattern(normalizedPath, pattern)) {
222
+ return false; // Exclude this file
223
+ }
224
+ }
225
+ }
226
+ // If include patterns are specified, file must match at least one
227
+ if (normalizedInclude && normalizedInclude.length > 0) {
228
+ for (const pattern of normalizedInclude) {
229
+ if (matchesPattern(normalizedPath, pattern)) {
230
+ return true; // Include this file
231
+ }
232
+ }
233
+ return false; // No include pattern matched
234
+ }
235
+ // No include patterns specified, file passed exclude check
236
+ return true;
237
+ });
238
+ }
239
+ // Apply cursor mode filtering if enabled
240
+ let processedFiles = filteredFiles;
241
+ if (options?.merge_strategy === 'cursor') {
242
+ const cursorFiles = [];
243
+ for (const file of filteredFiles) {
244
+ const relativePath = path.relative(rulerDir, file.path);
245
+ const normalizedPath = relativePath.replace(/\\/g, '/');
246
+ // Always include AGENTS.md for backward compatibility
247
+ if (/^AGENTS\.md$/i.test(normalizedPath)) {
248
+ cursorFiles.push(file);
249
+ continue;
250
+ }
251
+ // Check if file is in rules/ folder and is .mdc
252
+ if (normalizedPath.startsWith('rules/') && file.path.endsWith('.mdc')) {
253
+ // Parse frontmatter
254
+ const parsed = (0, FrontmatterParser_1.parseFrontmatter)(file.content);
255
+ // Only include if alwaysApply is true
256
+ if (parsed.frontmatter?.alwaysApply === true) {
257
+ // Strip frontmatter from content
258
+ cursorFiles.push({
259
+ path: file.path,
260
+ content: parsed.body,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ processedFiles = cursorFiles;
266
+ }
267
+ // Prioritisation logic:
268
+ // 1. Prefer top-level AGENTS.md if present.
269
+ // 2. If AGENTS.md absent but legacy instructions.md present, use it (no longer emits a warning; legacy accepted silently).
270
+ // 3. Include any remaining .md files (excluding whichever of the above was used if present) in
271
+ // sorted order AFTER the preferred primary file so that new concatenation priority starts with AGENTS.md.
272
+ const topLevelAgents = path.join(rulerDir, 'AGENTS.md');
273
+ const topLevelLegacy = path.join(rulerDir, 'instructions.md');
274
+ // Separate primary candidates from others
275
+ let primaryFile = null;
276
+ const others = [];
277
+ for (const f of processedFiles) {
278
+ if (f.path === topLevelAgents) {
279
+ primaryFile = f; // Highest priority
280
+ }
281
+ }
282
+ if (!primaryFile) {
283
+ for (const f of processedFiles) {
284
+ if (f.path === topLevelLegacy) {
285
+ primaryFile = f;
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ for (const f of processedFiles) {
291
+ if (primaryFile && f.path === primaryFile.path)
292
+ continue;
293
+ others.push(f);
294
+ }
295
+ // Sort the remaining others for stable deterministic concatenation order.
296
+ others.sort((a, b) => a.path.localeCompare(b.path));
297
+ let ordered = primaryFile ? [primaryFile, ...others] : others;
298
+ // NEW: Prepend repository root AGENTS.md (outside .ruler) if it exists and is not identical path.
299
+ try {
300
+ const repoRoot = path.dirname(rulerDir); // .ruler parent
301
+ const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
302
+ if (path.resolve(rootAgentsPath) !== path.resolve(topLevelAgents)) {
303
+ const stat = await fs_1.promises.stat(rootAgentsPath);
304
+ if (stat.isFile()) {
305
+ const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
306
+ // Check if this is a generated file and we have other .ruler files
307
+ const isGenerated = content.startsWith('<!-- Generated by Ruler -->');
308
+ const hasRulerFiles = others.length > 0 || primaryFile !== null;
309
+ // Additional check: if AGENTS.md contains ruler source comments and we have ruler files,
310
+ // it's likely a corrupted generated file that should be skipped
311
+ const containsRulerSources = content.includes('<!-- Source: .ruler/') ||
312
+ content.includes('<!-- Source: ruler/');
313
+ const isProbablyGenerated = isGenerated || (containsRulerSources && hasRulerFiles);
314
+ // Skip generated AGENTS.md if we have other files in .ruler
315
+ if (!isProbablyGenerated || !hasRulerFiles) {
316
+ // Prepend so it has highest precedence
317
+ ordered = [{ path: rootAgentsPath, content }, ...ordered];
318
+ }
319
+ }
320
+ }
321
+ }
322
+ catch {
323
+ // ignore if root AGENTS.md not present
324
+ }
325
+ return ordered;
326
+ }
327
+ /**
328
+ * Writes content to filePath, creating parent directories if necessary.
329
+ */
330
+ async function writeGeneratedFile(filePath, content) {
331
+ await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
332
+ await fs_1.promises.writeFile(filePath, content, 'utf8');
333
+ }
334
+ /**
335
+ * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
336
+ */
337
+ async function backupFile(filePath) {
338
+ try {
339
+ await fs_1.promises.access(filePath);
340
+ await fs_1.promises.copyFile(filePath, `${filePath}.bak`);
341
+ }
342
+ catch {
343
+ // ignore if file does not exist
344
+ }
345
+ }
346
+ /**
347
+ * Ensures that the given directory exists by creating it recursively.
348
+ */
349
+ async function ensureDirExists(dirPath) {
350
+ await fs_1.promises.mkdir(dirPath, { recursive: true });
351
+ }
352
+ /**
353
+ * Finds the global ruler configuration directory at XDG_CONFIG_HOME/ruler.
354
+ * Returns the path if it exists, null otherwise.
355
+ */
356
+ async function findGlobalRulerDir() {
357
+ const globalConfigDir = path.join(getXdgConfigDir(), 'ruler');
358
+ try {
359
+ const stat = await fs_1.promises.stat(globalConfigDir);
360
+ if (stat.isDirectory()) {
361
+ return globalConfigDir;
362
+ }
363
+ }
364
+ catch {
365
+ // ignore if global config doesn't exist
366
+ }
367
+ return null;
368
+ }
369
+ /**
370
+ * Searches the entire directory tree from startPath to find all .ruler and .claude directories with ruler.toml.
371
+ * Returns an array of directory paths from most specific to least specific.
372
+ */
373
+ async function findAllRulerDirs(startPath) {
374
+ const rulerDirs = [];
375
+ // Search the entire directory tree downwards from startPath
376
+ async function findRulerDirs(dir) {
377
+ try {
378
+ const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
379
+ for (const entry of entries) {
380
+ const fullPath = path.join(dir, entry.name);
381
+ if (entry.isDirectory()) {
382
+ if (entry.name === '.ruler') {
383
+ rulerDirs.push(fullPath);
384
+ }
385
+ else if (entry.name === '.claude') {
386
+ // Check if .claude has ruler.toml
387
+ const tomlPath = path.join(fullPath, 'ruler.toml');
388
+ try {
389
+ await fs_1.promises.stat(tomlPath);
390
+ rulerDirs.push(fullPath);
391
+ }
392
+ catch {
393
+ // .claude exists but no ruler.toml
394
+ }
395
+ }
396
+ else {
397
+ // Recursively search subdirectories (but skip hidden directories like .git)
398
+ if (!entry.name.startsWith('.')) {
399
+ await findRulerDirs(fullPath);
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ catch {
406
+ // ignore errors when reading directories
407
+ }
408
+ }
409
+ // Start searching from the startPath
410
+ await findRulerDirs(startPath);
411
+ // Sort by depth (most specific first) - deeper paths come first
412
+ rulerDirs.sort((a, b) => {
413
+ const depthA = a.split(path.sep).length;
414
+ const depthB = b.split(path.sep).length;
415
+ if (depthA !== depthB) {
416
+ return depthB - depthA; // Deeper paths first
417
+ }
418
+ return a.localeCompare(b); // Alphabetical for same depth
419
+ });
420
+ return rulerDirs;
421
+ }
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseFrontmatter = parseFrontmatter;
37
+ const yaml = __importStar(require("js-yaml"));
38
+ /**
39
+ * Parses YAML frontmatter from MDC file content.
40
+ * Frontmatter must be at the start of the file, between --- delimiters.
41
+ *
42
+ * @example
43
+ * ```
44
+ * ---
45
+ * description: My rule
46
+ * alwaysApply: true
47
+ * ---
48
+ * # Rule content here
49
+ * ```
50
+ *
51
+ * @param content The full file content
52
+ * @returns Object with parsed frontmatter and body (frontmatter stripped)
53
+ */
54
+ function parseFrontmatter(content) {
55
+ // Match YAML frontmatter at the start of the file
56
+ // Pattern: start of string, ---, content, ---, rest
57
+ // Allow for empty content between delimiters (consecutive ---\n--- lines)
58
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)---\s*\n([\s\S]*)$/;
59
+ const match = content.match(frontmatterRegex);
60
+ if (!match) {
61
+ // No frontmatter found
62
+ return {
63
+ frontmatter: null,
64
+ body: content,
65
+ };
66
+ }
67
+ const [, yamlContent, body] = match;
68
+ try {
69
+ // Try parsing YAML as-is first
70
+ const parsed = yaml.load(yamlContent);
71
+ return extractFrontmatter(parsed, body);
72
+ }
73
+ catch {
74
+ // YAML parsing failed - try to fix common issues
75
+ try {
76
+ // Fix common issue: globs as comma-separated unquoted strings
77
+ // Pattern: globs: *.tsx,**/path -> globs: ["*.tsx", "**/path"]
78
+ const fixedYaml = yamlContent.replace(/^(\s*globs\s*:\s*)([^\n[{]+)$/gm, (match, prefix, value) => {
79
+ // Check if value looks like comma-separated patterns (contains * or commas)
80
+ if (value.includes('*') || value.includes(',')) {
81
+ // Split by comma and quote each part
82
+ const patterns = value
83
+ .split(',')
84
+ .map((p) => p.trim())
85
+ .filter((p) => p.length > 0)
86
+ .map((p) => `"${p}"`)
87
+ .join(', ');
88
+ return `${prefix}[${patterns}]`;
89
+ }
90
+ return match;
91
+ });
92
+ // Try parsing again with fixed YAML
93
+ if (fixedYaml !== yamlContent) {
94
+ const parsed = yaml.load(fixedYaml);
95
+ return extractFrontmatter(parsed, body);
96
+ }
97
+ }
98
+ catch {
99
+ // Fixed version also failed, fall through to warning
100
+ }
101
+ // YAML parsing failed - treat as no frontmatter
102
+ return {
103
+ frontmatter: null,
104
+ body: content,
105
+ };
106
+ }
107
+ }
108
+ /**
109
+ * Extracts and validates frontmatter fields from parsed YAML.
110
+ */
111
+ function extractFrontmatter(parsed, body) {
112
+ // Extract and validate frontmatter fields
113
+ const frontmatter = {};
114
+ // Handle null or undefined parsed YAML (empty frontmatter)
115
+ if (!parsed) {
116
+ return {
117
+ frontmatter: {},
118
+ body: body.trim(),
119
+ };
120
+ }
121
+ if (typeof parsed === 'object') {
122
+ // Extract description
123
+ if (typeof parsed.description === 'string') {
124
+ frontmatter.description = parsed.description;
125
+ }
126
+ // Extract globs (can be string or array)
127
+ if (typeof parsed.globs === 'string') {
128
+ frontmatter.globs = [parsed.globs];
129
+ }
130
+ else if (Array.isArray(parsed.globs)) {
131
+ frontmatter.globs = parsed.globs.filter((g) => typeof g === 'string');
132
+ }
133
+ // Extract alwaysApply
134
+ if (typeof parsed.alwaysApply === 'boolean') {
135
+ frontmatter.alwaysApply = parsed.alwaysApply;
136
+ }
137
+ }
138
+ return {
139
+ frontmatter,
140
+ body: body.trim(),
141
+ };
142
+ }