From fcdfcd318612afc19a7f8809a173b9730e8e6184 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 11 Dec 2025 11:07:31 +0900 Subject: [PATCH] feat(hooks): add rules-injector hook for .cursor/rules and .claude/rules support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 2 + bun.lock | 10 +- package.json | 2 + src/hooks/index.ts | 1 + src/hooks/rules-injector/constants.ts | 23 +++ src/hooks/rules-injector/finder.ts | 237 ++++++++++++++++++++++++++ src/hooks/rules-injector/index.ts | 150 ++++++++++++++++ src/hooks/rules-injector/matcher.ts | 63 +++++++ src/hooks/rules-injector/parser.ts | 211 +++++++++++++++++++++++ src/hooks/rules-injector/storage.ts | 59 +++++++ src/hooks/rules-injector/types.ts | 43 +++++ src/index.ts | 4 + 12 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 src/hooks/rules-injector/constants.ts create mode 100644 src/hooks/rules-injector/finder.ts create mode 100644 src/hooks/rules-injector/index.ts create mode 100644 src/hooks/rules-injector/matcher.ts create mode 100644 src/hooks/rules-injector/parser.ts create mode 100644 src/hooks/rules-injector/storage.ts create mode 100644 src/hooks/rules-injector/types.ts diff --git a/.gitignore b/.gitignore index 639c215..cbe3744 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ yarn.lock # Environment .env .env.local +test-injection/ +notepad.md diff --git a/bun.lock b/bun.lock index 388ffca..daf00a1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 6c68446..3332e04 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c05e16c..b488ce8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/hooks/rules-injector/constants.ts b/src/hooks/rules-injector/constants.ts new file mode 100644 index 0000000..1e2ebb3 --- /dev/null +++ b/src/hooks/rules-injector/constants.ts @@ -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"]; diff --git a/src/hooks/rules-injector/finder.ts b/src/hooks/rules-injector/finder.ts new file mode 100644 index 0000000..6dd1237 --- /dev/null +++ b/src/hooks/rules-injector/finder.ts @@ -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(); + + // 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; +} diff --git a/src/hooks/rules-injector/index.ts b/src/hooks/rules-injector/index.ts new file mode 100644 index 0000000..0279afe --- /dev/null +++ b/src/hooks/rules-injector/index.ts @@ -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; realPaths: Set } + >(); + + function getSessionCache(sessionID: string): { + contentHashes: Set; + realPaths: Set; + } { + 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 | 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, + }; +} diff --git a/src/hooks/rules-injector/matcher.ts b/src/hooks/rules-injector/matcher.ts new file mode 100644 index 0000000..13f6d51 --- /dev/null +++ b/src/hooks/rules-injector/matcher.ts @@ -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): 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): boolean { + return cache.has(hash) +} diff --git a/src/hooks/rules-injector/parser.ts b/src/hooks/rules-injector/parser.ts new file mode 100644 index 0000000..2a96675 --- /dev/null +++ b/src/hooks/rules-injector/parser.ts @@ -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]; +} diff --git a/src/hooks/rules-injector/storage.ts b/src/hooks/rules-injector/storage.ts new file mode 100644 index 0000000..fc01a59 --- /dev/null +++ b/src/hooks/rules-injector/storage.ts @@ -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; + realPaths: Set; +} { + 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; realPaths: Set } +): 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); + } +} diff --git a/src/hooks/rules-injector/types.ts b/src/hooks/rules-injector/types.ts new file mode 100644 index 0000000..f065fa7 --- /dev/null +++ b/src/hooks/rules-injector/types.ts @@ -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; +} diff --git a/src/index.ts b/src/index.ts index f6d71c4..c57da02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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()) {