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:
YeonGyu-Kim
2025-12-11 11:07:31 +09:00
parent c12f73f774
commit fcdfcd3186
12 changed files with 803 additions and 2 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ yarn.lock
# Environment
.env
.env.local
test-injection/
notepad.md

View File

@@ -7,12 +7,14 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.4.4",
"@code-yeongyu/comment-checker": "^0.5.0",
"@opencode-ai/plugin": "^1.0.7",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8",
},
"devDependencies": {
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3",
@@ -64,7 +66,7 @@
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.5.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-rKD2qQnTVUacsVQtpu3I5Sxi09X/XpOwS9fcmbUv1yfUL6llraaPuLmmxMBMRcmm7Zu31yEPVKCeUkVODfRL1g=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
@@ -94,6 +96,8 @@
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
"bun": ["bun@1.3.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.3", "@oven/bun-darwin-x64": "1.3.3", "@oven/bun-darwin-x64-baseline": "1.3.3", "@oven/bun-linux-aarch64": "1.3.3", "@oven/bun-linux-aarch64-musl": "1.3.3", "@oven/bun-linux-x64": "1.3.3", "@oven/bun-linux-x64-baseline": "1.3.3", "@oven/bun-linux-x64-musl": "1.3.3", "@oven/bun-linux-x64-musl-baseline": "1.3.3", "@oven/bun-windows-x64": "1.3.3", "@oven/bun-windows-x64-baseline": "1.3.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
@@ -102,6 +106,8 @@
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@@ -46,10 +46,12 @@
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.5.0",
"@opencode-ai/plugin": "^1.0.7",
"picomatch": "^4.0.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3"

View File

@@ -10,3 +10,4 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";

View File

@@ -0,0 +1,23 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
export const PROJECT_MARKERS = [
".git",
"pyproject.toml",
"package.json",
"Cargo.toml",
"go.mod",
".venv",
];
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
[".cursor", "rules"],
[".claude", "rules"],
];
export const USER_RULE_DIR = ".claude/rules";
export const RULE_EXTENSIONS = [".md", ".mdc"];

View File

@@ -0,0 +1,237 @@
import {
existsSync,
readdirSync,
realpathSync,
statSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
import {
PROJECT_MARKERS,
PROJECT_RULE_SUBDIRS,
RULE_EXTENSIONS,
USER_RULE_DIR,
} from "./constants";
/**
* Candidate rule file with metadata for filtering and sorting
*/
export interface RuleFileCandidate {
/** Absolute path to the rule file */
path: string;
/** Real path after symlink resolution (for duplicate detection) */
realPath: string;
/** Whether this is a global/user-level rule */
isGlobal: boolean;
/** Directory distance from current file (9999 for global rules) */
distance: number;
}
/**
* Find project root by walking up from startPath.
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
*
* @param startPath - Starting path to search from (file or directory)
* @returns Project root path or null if not found
*/
export function findProjectRoot(startPath: string): string | null {
let current: string;
try {
const stat = statSync(startPath);
current = stat.isDirectory() ? startPath : dirname(startPath);
} catch {
current = dirname(startPath);
}
while (true) {
for (const marker of PROJECT_MARKERS) {
const markerPath = join(current, marker);
if (existsSync(markerPath)) {
return current;
}
}
const parent = dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
/**
* Recursively find all rule files (*.md, *.mdc) in a directory
*
* @param dir - Directory to search
* @param results - Array to accumulate results
*/
function findRuleFilesRecursive(dir: string, results: string[]): void {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
findRuleFilesRecursive(fullPath, results);
} else if (entry.isFile()) {
const isRuleFile = RULE_EXTENSIONS.some((ext) =>
entry.name.endsWith(ext),
);
if (isRuleFile) {
results.push(fullPath);
}
}
}
} catch {
// Permission denied or other errors - silently skip
}
}
/**
* Resolve symlinks safely with fallback to original path
*
* @param filePath - Path to resolve
* @returns Real path or original path if resolution fails
*/
function safeRealpathSync(filePath: string): string {
try {
return realpathSync(filePath);
} catch {
return filePath;
}
}
/**
* Calculate directory distance between a rule file and current file.
* Distance is based on common ancestor within project root.
*
* @param rulePath - Path to the rule file
* @param currentFile - Path to the current file being edited
* @param projectRoot - Project root for relative path calculation
* @returns Distance (0 = same directory, higher = further)
*/
export function calculateDistance(
rulePath: string,
currentFile: string,
projectRoot: string | null,
): number {
if (!projectRoot) {
return 9999;
}
try {
const ruleDir = dirname(rulePath);
const currentDir = dirname(currentFile);
const ruleRel = relative(projectRoot, ruleDir);
const currentRel = relative(projectRoot, currentDir);
// Handle paths outside project root
if (ruleRel.startsWith("..") || currentRel.startsWith("..")) {
return 9999;
}
const ruleParts = ruleRel ? ruleRel.split("/") : [];
const currentParts = currentRel ? currentRel.split("/") : [];
// Find common prefix length
let common = 0;
for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) {
if (ruleParts[i] === currentParts[i]) {
common++;
} else {
break;
}
}
// Distance is how many directories up from current file to common ancestor
return currentParts.length - common;
} catch {
return 9999;
}
}
/**
* Find all rule files for a given context.
* Searches from currentFile upward to projectRoot for rule directories,
* then user-level directory (~/.claude/rules).
*
* IMPORTANT: This searches EVERY directory from file to project root.
* Not just the project root itself.
*
* @param projectRoot - Project root path (or null if outside any project)
* @param homeDir - User home directory
* @param currentFile - Current file being edited (for distance calculation)
* @returns Array of rule file candidates sorted by distance
*/
export function findRuleFiles(
projectRoot: string | null,
homeDir: string,
currentFile: string,
): RuleFileCandidate[] {
const candidates: RuleFileCandidate[] = [];
const seenRealPaths = new Set<string>();
// Search from current file's directory up to project root
let currentDir = dirname(currentFile);
let distance = 0;
while (true) {
// Search rule directories in current directory
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
const ruleDir = join(currentDir, parent, subdir);
const files: string[] = [];
findRuleFilesRecursive(ruleDir, files);
for (const filePath of files) {
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance,
});
}
}
// Stop at project root or filesystem root
if (projectRoot && currentDir === projectRoot) break;
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
distance++;
}
// Search user-level rule directory (~/.claude/rules)
const userRuleDir = join(homeDir, USER_RULE_DIR);
const userFiles: string[] = [];
findRuleFilesRecursive(userRuleDir, userFiles);
for (const filePath of userFiles) {
const realPath = safeRealpathSync(filePath);
if (seenRealPaths.has(realPath)) continue;
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: true,
distance: 9999, // Global rules always have max distance
});
}
// Sort by distance (closest first, then global rules last)
candidates.sort((a, b) => {
if (a.isGlobal !== b.isGlobal) {
return a.isGlobal ? 1 : -1;
}
return a.distance - b.distance;
});
return candidates;
}

View File

@@ -0,0 +1,150 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { relative, resolve } from "node:path";
import { findProjectRoot, findRuleFiles } from "./finder";
import {
createContentHash,
isDuplicateByContentHash,
isDuplicateByRealPath,
shouldApplyRule,
} from "./matcher";
import { parseRuleFrontmatter } from "./parser";
import {
clearInjectedRules,
loadInjectedRules,
saveInjectedRules,
} from "./storage";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
interface RuleToInject {
relativePath: string;
matchReason: string;
content: string;
distance: number;
}
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
export function createRulesInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<
string,
{ contentHashes: Set<string>; realPaths: Set<string> }
>();
function getSessionCache(sessionID: string): {
contentHashes: Set<string>;
realPaths: Set<string>;
} {
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
}
return sessionCaches.get(sessionID)!;
}
function resolveFilePath(title: string): string | null {
if (!title) return null;
if (title.startsWith("/")) return title;
return resolve(ctx.directory, title);
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput
) => {
if (!TRACKED_TOOLS.includes(input.tool.toLowerCase())) return;
const filePath = resolveFilePath(output.title);
if (!filePath) return;
const projectRoot = findProjectRoot(filePath);
const cache = getSessionCache(input.sessionID);
const home = homedir();
const ruleFileCandidates = findRuleFiles(projectRoot, home, filePath);
const toInject: RuleToInject[] = [];
for (const candidate of ruleFileCandidates) {
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
try {
const rawContent = readFileSync(candidate.path, "utf-8");
const { metadata, body } = parseRuleFrontmatter(rawContent);
const matchResult = shouldApplyRule(metadata, filePath, projectRoot);
if (!matchResult.applies) continue;
const contentHash = createContentHash(body);
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
const relativePath = projectRoot
? relative(projectRoot, candidate.path)
: candidate.path;
toInject.push({
relativePath,
matchReason: matchResult.reason ?? "matched",
content: body,
distance: candidate.distance,
});
cache.realPaths.add(candidate.realPath);
cache.contentHashes.add(contentHash);
} catch {}
}
if (toInject.length === 0) return;
toInject.sort((a, b) => a.distance - b.distance);
for (const rule of toInject) {
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${rule.content}`;
}
saveInjectedRules(input.sessionID, cache);
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedRules(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedRules(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,63 @@
import { createHash } from "crypto"
import { relative } from "node:path"
import picomatch from "picomatch"
import type { RuleMetadata } from "./types"
export interface MatchResult {
applies: boolean
reason?: string
}
/**
* Check if a rule should apply to the current file based on metadata
*/
export function shouldApplyRule(
metadata: RuleMetadata,
currentFilePath: string,
projectRoot: string | null
): MatchResult {
if (metadata.alwaysApply === true) {
return { applies: true, reason: "alwaysApply" }
}
const globs = metadata.globs
if (!globs) {
return { applies: false }
}
const patterns = Array.isArray(globs) ? globs : [globs]
if (patterns.length === 0) {
return { applies: false }
}
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
for (const pattern of patterns) {
if (picomatch.isMatch(relativePath, pattern, { dot: true, bash: true })) {
return { applies: true, reason: `glob: ${pattern}` }
}
}
return { applies: false }
}
/**
* Check if realPath already exists in cache (symlink deduplication)
*/
export function isDuplicateByRealPath(realPath: string, cache: Set<string>): boolean {
return cache.has(realPath)
}
/**
* Create SHA-256 hash of content, truncated to 16 chars
*/
export function createContentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16)
}
/**
* Check if content hash already exists in cache
*/
export function isDuplicateByContentHash(hash: string, cache: Set<string>): boolean {
return cache.has(hash)
}

View File

@@ -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];
}

View File

@@ -0,0 +1,59 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
import type { InjectedRulesData } from "./types";
function getStoragePath(sessionID: string): string {
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedRules(sessionID: string): {
contentHashes: Set<string>;
realPaths: Set<string>;
} {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath))
return { contentHashes: new Set(), realPaths: new Set() };
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedRulesData = JSON.parse(content);
return {
contentHashes: new Set(data.injectedHashes),
realPaths: new Set(data.injectedRealPaths ?? []),
};
} catch {
return { contentHashes: new Set(), realPaths: new Set() };
}
}
export function saveInjectedRules(
sessionID: string,
data: { contentHashes: Set<string>; realPaths: Set<string> }
): void {
if (!existsSync(RULES_INJECTOR_STORAGE)) {
mkdirSync(RULES_INJECTOR_STORAGE, { recursive: true });
}
const storageData: InjectedRulesData = {
sessionID,
injectedHashes: [...data.contentHashes],
injectedRealPaths: [...data.realPaths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(storageData, null, 2));
}
export function clearInjectedRules(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,43 @@
/**
* Rule file metadata (Claude Code style frontmatter)
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
*/
export interface RuleMetadata {
description?: string;
globs?: string | string[];
alwaysApply?: boolean;
}
/**
* Rule information with path context and content
*/
export interface RuleInfo {
/** Absolute path to the rule file */
path: string;
/** Path relative to project root */
relativePath: string;
/** Directory distance from target file (0 = same dir) */
distance: number;
/** Rule file content (without frontmatter) */
content: string;
/** SHA-256 hash of content for deduplication */
contentHash: string;
/** Parsed frontmatter metadata */
metadata: RuleMetadata;
/** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */
matchReason: string;
/** Real path after symlink resolution (for duplicate detection) */
realPath: string;
}
/**
* Session storage for injected rules tracking
*/
export interface InjectedRulesData {
sessionID: string;
/** Content hashes of already injected rules */
injectedHashes: string[];
/** Real paths of already injected rules (for symlink deduplication) */
injectedRealPaths: string[];
updatedAt: number;
}

View File

@@ -12,6 +12,7 @@ import {
createThinkModeHook,
createClaudeCodeHooksHook,
createAnthropicAutoCompactHook,
createRulesInjectorHook,
} from "./hooks";
import {
loadUserCommands,
@@ -157,6 +158,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
});
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
const rulesInjector = createRulesInjectorHook(ctx);
updateTerminalTitle({ sessionId: "main" });
@@ -220,6 +222,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
await directoryReadmeInjector.event(input);
await rulesInjector.event(input);
await thinkMode.event(input);
await anthropicAutoCompact.event(input);
@@ -339,6 +342,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await commentChecker["tool.execute.after"](input, output);
await directoryAgentsInjector["tool.execute.after"](input, output);
await directoryReadmeInjector["tool.execute.after"](input, output);
await rulesInjector["tool.execute.after"](input, output);
await emptyTaskResponseDetector["tool.execute.after"](input, output);
if (input.sessionID === getMainSessionID()) {