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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -25,3 +25,5 @@ yarn.lock
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
test-injection/
|
||||||
|
notepad.md
|
||||||
|
|||||||
10
bun.lock
10
bun.lock
@@ -7,12 +7,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ast-grep/cli": "^0.40.0",
|
"@ast-grep/cli": "^0.40.0",
|
||||||
"@ast-grep/napi": "^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",
|
"@opencode-ai/plugin": "^1.0.7",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
"xdg-basedir": "^5.1.0",
|
"xdg-basedir": "^5.1.0",
|
||||||
"zod": "^4.1.8",
|
"zod": "^4.1.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/picomatch": "^3.0.2",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"oh-my-opencode": "^0.1.30",
|
"oh-my-opencode": "^0.1.30",
|
||||||
"typescript": "^5.7.3",
|
"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=="],
|
"@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=="],
|
"@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/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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|||||||
@@ -46,10 +46,12 @@
|
|||||||
"@ast-grep/napi": "^0.40.0",
|
"@ast-grep/napi": "^0.40.0",
|
||||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||||
"@opencode-ai/plugin": "^1.0.7",
|
"@opencode-ai/plugin": "^1.0.7",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
"xdg-basedir": "^5.1.0",
|
"xdg-basedir": "^5.1.0",
|
||||||
"zod": "^4.1.8"
|
"zod": "^4.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/picomatch": "^3.0.2",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"oh-my-opencode": "^0.1.30",
|
"oh-my-opencode": "^0.1.30",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
|
|||||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||||
export { createThinkModeHook } from "./think-mode";
|
export { createThinkModeHook } from "./think-mode";
|
||||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
|
export { createRulesInjectorHook } from "./rules-injector";
|
||||||
|
|||||||
23
src/hooks/rules-injector/constants.ts
Normal file
23
src/hooks/rules-injector/constants.ts
Normal 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"];
|
||||||
237
src/hooks/rules-injector/finder.ts
Normal file
237
src/hooks/rules-injector/finder.ts
Normal 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;
|
||||||
|
}
|
||||||
150
src/hooks/rules-injector/index.ts
Normal file
150
src/hooks/rules-injector/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/hooks/rules-injector/matcher.ts
Normal file
63
src/hooks/rules-injector/matcher.ts
Normal 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)
|
||||||
|
}
|
||||||
211
src/hooks/rules-injector/parser.ts
Normal file
211
src/hooks/rules-injector/parser.ts
Normal 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];
|
||||||
|
}
|
||||||
59
src/hooks/rules-injector/storage.ts
Normal file
59
src/hooks/rules-injector/storage.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/hooks/rules-injector/types.ts
Normal file
43
src/hooks/rules-injector/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createThinkModeHook,
|
createThinkModeHook,
|
||||||
createClaudeCodeHooksHook,
|
createClaudeCodeHooksHook,
|
||||||
createAnthropicAutoCompactHook,
|
createAnthropicAutoCompactHook,
|
||||||
|
createRulesInjectorHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import {
|
import {
|
||||||
loadUserCommands,
|
loadUserCommands,
|
||||||
@@ -157,6 +158,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||||
});
|
});
|
||||||
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
||||||
|
const rulesInjector = createRulesInjectorHook(ctx);
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -220,6 +222,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
await contextWindowMonitor.event(input);
|
await contextWindowMonitor.event(input);
|
||||||
await directoryAgentsInjector.event(input);
|
await directoryAgentsInjector.event(input);
|
||||||
await directoryReadmeInjector.event(input);
|
await directoryReadmeInjector.event(input);
|
||||||
|
await rulesInjector.event(input);
|
||||||
await thinkMode.event(input);
|
await thinkMode.event(input);
|
||||||
await anthropicAutoCompact.event(input);
|
await anthropicAutoCompact.event(input);
|
||||||
|
|
||||||
@@ -339,6 +342,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
await commentChecker["tool.execute.after"](input, output);
|
await commentChecker["tool.execute.after"](input, output);
|
||||||
await directoryAgentsInjector["tool.execute.after"](input, output);
|
await directoryAgentsInjector["tool.execute.after"](input, output);
|
||||||
await directoryReadmeInjector["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);
|
await emptyTaskResponseDetector["tool.execute.after"](input, output);
|
||||||
|
|
||||||
if (input.sessionID === getMainSessionID()) {
|
if (input.sessionID === getMainSessionID()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user