feat(hooks): add rules-injector hook for .cursor/rules and .claude/rules support
Implements adaptive rule injection similar to Claude Code's rule system: - Searches .cursor/rules and .claude/rules directories recursively - Supports YAML frontmatter with globs, paths, alwaysApply, description - Adaptive project root detection (finds markers even outside ctx.directory) - Symlink duplicate detection via realpath comparison - Content hash deduplication (SHA-256) to avoid re-injecting same rules - picomatch-based glob pattern matching for file-specific rules 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import type { RuleMetadata } from "./types";
|
||||
|
||||
export interface RuleFrontmatterResult {
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from rule file content
|
||||
* Supports:
|
||||
* - Single string: globs: "**\/*.py"
|
||||
* - Inline array: globs: ["**\/*.py", "src/**\/*.ts"]
|
||||
* - Multi-line array:
|
||||
* globs:
|
||||
* - "**\/*.py"
|
||||
* - "src/**\/*.ts"
|
||||
* - Comma-separated: globs: "**\/*.py, src/**\/*.ts"
|
||||
* - Claude Code 'paths' field (alias for globs)
|
||||
*/
|
||||
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
|
||||
const yamlContent = match[1];
|
||||
const body = match[2];
|
||||
|
||||
try {
|
||||
const metadata = parseYamlContent(yamlContent);
|
||||
return { metadata, body };
|
||||
} catch {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML content without external library
|
||||
*/
|
||||
function parseYamlContent(yamlContent: string): RuleMetadata {
|
||||
const lines = yamlContent.split("\n");
|
||||
const metadata: RuleMetadata = {};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const colonIndex = line.indexOf(":");
|
||||
|
||||
if (colonIndex === -1) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const rawValue = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key === "description") {
|
||||
metadata.description = parseStringValue(rawValue);
|
||||
} else if (key === "alwaysApply") {
|
||||
metadata.alwaysApply = rawValue === "true";
|
||||
} else if (key === "globs" || key === "paths") {
|
||||
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
|
||||
// Merge paths into globs (Claude Code compatibility)
|
||||
if (key === "paths") {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
} else {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
}
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string value, removing surrounding quotes
|
||||
*/
|
||||
function parseStringValue(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse array or string value from YAML
|
||||
* Returns the parsed value and number of lines consumed
|
||||
*/
|
||||
function parseArrayOrStringValue(
|
||||
rawValue: string,
|
||||
lines: string[],
|
||||
currentIndex: number
|
||||
): { value: string | string[]; consumed: number } {
|
||||
// Case 1: Inline array ["a", "b", "c"]
|
||||
if (rawValue.startsWith("[")) {
|
||||
return { value: parseInlineArray(rawValue), consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 2: Multi-line array (value is empty, next lines start with " - ")
|
||||
if (!rawValue || rawValue === "") {
|
||||
const arrayItems: string[] = [];
|
||||
let consumed = 1;
|
||||
|
||||
for (let j = currentIndex + 1; j < lines.length; j++) {
|
||||
const nextLine = lines[j];
|
||||
|
||||
// Check if this is an array item (starts with whitespace + dash)
|
||||
const arrayMatch = nextLine.match(/^\s+-\s*(.*)$/);
|
||||
if (arrayMatch) {
|
||||
const itemValue = parseStringValue(arrayMatch[1].trim());
|
||||
if (itemValue) {
|
||||
arrayItems.push(itemValue);
|
||||
}
|
||||
consumed++;
|
||||
} else if (nextLine.trim() === "") {
|
||||
// Skip empty lines within array
|
||||
consumed++;
|
||||
} else {
|
||||
// Not an array item, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayItems.length > 0) {
|
||||
return { value: arrayItems, consumed };
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Comma-separated patterns in single string
|
||||
const stringValue = parseStringValue(rawValue);
|
||||
if (stringValue.includes(",")) {
|
||||
const items = stringValue
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
return { value: items, consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 4: Single string value
|
||||
return { value: stringValue, consumed: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline JSON-like array: ["a", "b", "c"]
|
||||
*/
|
||||
function parseInlineArray(value: string): string[] {
|
||||
// Remove brackets
|
||||
const content = value.slice(1, value.lastIndexOf("]")).trim();
|
||||
if (!content) return [];
|
||||
|
||||
const items: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
let quoteChar = "";
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
|
||||
if (!inQuote && (char === '"' || char === "'")) {
|
||||
inQuote = true;
|
||||
quoteChar = char;
|
||||
} else if (inQuote && char === quoteChar) {
|
||||
inQuote = false;
|
||||
quoteChar = "";
|
||||
} else if (!inQuote && char === ",") {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last item
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two globs values (for combining paths and globs)
|
||||
*/
|
||||
function mergeGlobs(
|
||||
existing: string | string[] | undefined,
|
||||
newValue: string | string[]
|
||||
): string | string[] {
|
||||
if (!existing) return newValue;
|
||||
|
||||
const existingArray = Array.isArray(existing) ? existing : [existing];
|
||||
const newArray = Array.isArray(newValue) ? newValue : [newValue];
|
||||
|
||||
return [...existingArray, ...newArray];
|
||||
}
|
||||
Reference in New Issue
Block a user