feat(omo-codex): add PLUGIN_BUNDLED rule source to codex-rules engine
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,10 +2,9 @@ import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { defaultConfig } from "./rules/engine.js";
|
||||
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
|
||||
|
||||
const MODE_VALUES = new Set<PiRulesConfig["mode"]>(["static", "dynamic", "both", "off"]);
|
||||
|
||||
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
|
||||
const config = defaultConfig();
|
||||
const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED"));
|
||||
config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
|
||||
config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
|
||||
config.maxRuleChars =
|
||||
@@ -16,6 +15,7 @@ export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiR
|
||||
config.maxResultChars;
|
||||
config.enabledSources = parseEnabledSources(
|
||||
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
||||
disableBundledRules,
|
||||
);
|
||||
return config;
|
||||
}
|
||||
@@ -38,7 +38,15 @@ function isTruthy(value: string | undefined): boolean {
|
||||
function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined;
|
||||
switch (normalized) {
|
||||
case "static":
|
||||
case "dynamic":
|
||||
case "both":
|
||||
case "off":
|
||||
return normalized;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
@@ -47,19 +55,45 @@ function parsePositiveInteger(value: string | undefined): number | undefined {
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" {
|
||||
function parseEnabledSources(value: string | undefined, disableBundledRules: boolean): RuleSource[] | "auto" {
|
||||
if (value === undefined || value.trim().toLowerCase() === "auto") {
|
||||
return "auto";
|
||||
return disableBundledRules ? sourcesWithoutBundledRules() : "auto";
|
||||
}
|
||||
|
||||
const validSources = new Set(SOURCE_PRIORITY.keys());
|
||||
const sources: RuleSource[] = [];
|
||||
for (const rawSource of value.split(",")) {
|
||||
const source = rawSource.trim();
|
||||
if (!validSources.has(source as RuleSource)) {
|
||||
const source = toRuleSource(rawSource.trim());
|
||||
if (source === null) {
|
||||
continue;
|
||||
}
|
||||
sources.push(source as RuleSource);
|
||||
sources.push(source);
|
||||
}
|
||||
const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources;
|
||||
return enabledSources.length > 0 || sources.length > 0 ? enabledSources : "auto";
|
||||
}
|
||||
|
||||
function sourcesWithoutBundledRules(): RuleSource[] {
|
||||
return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled");
|
||||
}
|
||||
|
||||
function toRuleSource(value: string): RuleSource | null {
|
||||
switch (value) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
case "plugin-bundled":
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return sources.length > 0 ? sources : "auto";
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".openco
|
||||
*/
|
||||
export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"];
|
||||
|
||||
/**
|
||||
* Bundled plugin rule directory relative to the rules component root.
|
||||
*/
|
||||
export const BUNDLED_RULE_SUBDIR = "bundled-rules";
|
||||
|
||||
/**
|
||||
* File extensions accepted as rule files in scanned directories.
|
||||
*/
|
||||
@@ -67,6 +72,7 @@ export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
|
||||
["~/.claude/rules", 102],
|
||||
["~/.config/opencode/AGENTS.md", 103],
|
||||
["~/.claude/CLAUDE.md", 104],
|
||||
["plugin-bundled", 200],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
|
||||
type ScannedRuleFiles = ReturnType<typeof scanRuleFiles>;
|
||||
|
||||
interface SingleFileInfo {
|
||||
readonly path: string;
|
||||
readonly realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
readonly scannedRuleFiles: Map<string, ScannedRuleFiles>;
|
||||
readonly singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
}
|
||||
|
||||
export function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles {
|
||||
if (cache === undefined) {
|
||||
return scanRuleFiles({ rootDir });
|
||||
}
|
||||
|
||||
const cached = cache.scannedRuleFiles.get(rootDir);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const scannedFiles = scanRuleFiles({ rootDir });
|
||||
cache.scannedRuleFiles.set(rootDir, scannedFiles);
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
export function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
|
||||
if (cache === undefined) {
|
||||
return readSingleFileInfo(filePath);
|
||||
}
|
||||
|
||||
const cached = cache.singleFileInfo.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fileInfo = readSingleFileInfo(filePath);
|
||||
cache.singleFileInfo.set(filePath, fileInfo);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: filePath, realPath: resolveRealPath(filePath) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { dirname, posix, relative, resolve } from "node:path";
|
||||
|
||||
export interface WalkDirectory {
|
||||
readonly directory: string;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
|
||||
if (targetFile === null) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const startDirectory = dirname(resolve(targetFile));
|
||||
if (!isSameOrChildPath(startDirectory, projectRoot)) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const walkDirectories: WalkDirectory[] = [];
|
||||
let currentDirectory = startDirectory;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
walkDirectories.push({ directory: currentDirectory, distance });
|
||||
if (currentDirectory === projectRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return walkDirectories;
|
||||
}
|
||||
|
||||
export function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import type { RuleSource } from "./types.js";
|
||||
|
||||
export function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
|
||||
const source = `${parentDirectory}/${subDirectory}`;
|
||||
switch (source) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toProjectSingleFileSource(ruleFile: string): RuleSource {
|
||||
switch (ruleFile) {
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
return ruleFile;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
|
||||
const source = `~/${ruleSubdir}`;
|
||||
switch (source) {
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
|
||||
const source = `~/${ruleFile}`;
|
||||
switch (source) {
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, posix, relative, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
GLOBAL_DISTANCE,
|
||||
BUNDLED_RULE_SUBDIR,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
USER_HOME_RULE_SUBDIRS,
|
||||
USER_HOME_SINGLE_FILES,
|
||||
} from "./constants.js";
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
import type { RuleCandidate, RuleSource } from "./types.js";
|
||||
import { type RuleDiscoveryCache, scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js";
|
||||
import { getWalkDirectories, toRelativePath } from "./finder-paths.js";
|
||||
import {
|
||||
toProjectRuleSource,
|
||||
toProjectSingleFileSource,
|
||||
toUserHomeRuleSource,
|
||||
toUserHomeSingleFileSource,
|
||||
} from "./finder-sources.js";
|
||||
import { resolvePluginRulesRoot } from "./plugin-root.js";
|
||||
import type { RuleCandidate } from "./types.js";
|
||||
|
||||
interface SingleFileInfo {
|
||||
path: string;
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
scannedRuleFiles: Map<string, ReturnType<typeof scanRuleFiles>>;
|
||||
singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
export type { RuleDiscoveryCache } from "./finder-cache.js";
|
||||
export { createRuleDiscoveryCache } from "./finder-cache.js";
|
||||
|
||||
export interface FinderOptions {
|
||||
/** Project root absolute path (use findProjectRoot to get this). */
|
||||
@@ -34,24 +34,19 @@ export interface FinderOptions {
|
||||
disabledSources?: ReadonlySet<string>;
|
||||
/** Whether to skip user-home rules. Default: false. */
|
||||
skipUserHome?: boolean;
|
||||
/** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */
|
||||
pluginRoot?: string;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}
|
||||
|
||||
interface WalkDirectory {
|
||||
directory: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
interface PluginBundledFinderOptions {
|
||||
readonly disabledSources?: ReadonlySet<string>;
|
||||
readonly cache?: RuleDiscoveryCache;
|
||||
readonly pluginRoot?: string;
|
||||
}
|
||||
|
||||
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
const skipUserHome = options.skipUserHome ?? false;
|
||||
if (options.projectRoot === null && skipUserHome) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disabledSources = options.disabledSources ?? new Set<string>();
|
||||
const candidates: RuleCandidate[] = [];
|
||||
const homeDirectory = resolve(options.homeDir ?? homedir());
|
||||
@@ -62,6 +57,13 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
);
|
||||
}
|
||||
|
||||
const pluginBundledOptions: PluginBundledFinderOptions = {
|
||||
disabledSources,
|
||||
...(options.cache === undefined ? {} : { cache: options.cache }),
|
||||
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
|
||||
};
|
||||
candidates.push(...findPluginBundledCandidates(pluginBundledOptions));
|
||||
|
||||
if (!skipUserHome) {
|
||||
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
|
||||
}
|
||||
@@ -69,6 +71,28 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function findPluginBundledCandidates(options: PluginBundledFinderOptions = {}): RuleCandidate[] {
|
||||
if (options.disabledSources?.has("plugin-bundled") === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginRoot = resolvePluginRulesRoot(options.pluginRoot);
|
||||
const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR);
|
||||
const candidates: RuleCandidate[] = [];
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source: "plugin-bundled",
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(pluginRoot, scannedFile.path),
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findProjectCandidates(
|
||||
projectRoot: string,
|
||||
targetFile: string | null,
|
||||
@@ -181,146 +205,3 @@ function findUserHomeCandidates(
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType<typeof scanRuleFiles> {
|
||||
if (cache === undefined) {
|
||||
return scanRuleFiles({ rootDir });
|
||||
}
|
||||
|
||||
const cached = cache.scannedRuleFiles.get(rootDir);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const scannedFiles = scanRuleFiles({ rootDir });
|
||||
cache.scannedRuleFiles.set(rootDir, scannedFiles);
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
|
||||
if (cache === undefined) {
|
||||
return readSingleFileInfo(filePath);
|
||||
}
|
||||
|
||||
const cached = cache.singleFileInfo.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fileInfo = readSingleFileInfo(filePath);
|
||||
cache.singleFileInfo.set(filePath, fileInfo);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
|
||||
if (targetFile === null) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const startDirectory = dirname(resolve(targetFile));
|
||||
if (!isSameOrChildPath(startDirectory, projectRoot)) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const walkDirectories: WalkDirectory[] = [];
|
||||
let currentDirectory = startDirectory;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
walkDirectories.push({ directory: currentDirectory, distance });
|
||||
if (currentDirectory === projectRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return walkDirectories;
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
|
||||
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: filePath, realPath: resolveRealPath(filePath) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
|
||||
const source = `${parentDirectory}/${subDirectory}`;
|
||||
switch (source) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toProjectSingleFileSource(ruleFile: string): RuleSource {
|
||||
switch (ruleFile) {
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
return ruleFile;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
|
||||
const source = `~/${ruleSubdir}`;
|
||||
switch (source) {
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
|
||||
const source = `~/${ruleFile}`;
|
||||
switch (source) {
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,39 @@ export function formatStaticBlock(rules: ReadonlyArray<LoadedRule>, options: For
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`;
|
||||
return `\n\n## Project Instructions\n${truncateRules(staticDisplayRules(rules), options).map(formatRule).join("\n\n")}`;
|
||||
}
|
||||
|
||||
function staticDisplayRules(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
|
||||
const uniqueRules = uniqueRulesByBody(rules);
|
||||
return [
|
||||
...uniqueRules.filter((rule) => rule.source === "plugin-bundled"),
|
||||
...uniqueRules.filter((rule) => rule.source !== "plugin-bundled"),
|
||||
];
|
||||
}
|
||||
|
||||
function uniqueRulesByBody(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
|
||||
const uniqueRules: LoadedRule[] = [];
|
||||
const seenBodies = new Set<string>();
|
||||
const userDescriptions = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
const descriptionKey = rule.frontmatter.description?.trim();
|
||||
if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bodyKey = rule.body.trim();
|
||||
if (seenBodies.has(bodyKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenBodies.add(bodyKey);
|
||||
if (descriptionKey !== undefined && rule.source !== "plugin-bundled") {
|
||||
userDescriptions.add(descriptionKey);
|
||||
}
|
||||
uniqueRules.push(rule);
|
||||
}
|
||||
return uniqueRules;
|
||||
}
|
||||
|
||||
export function formatDynamicBlock(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json");
|
||||
|
||||
export function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl = import.meta.url): string {
|
||||
const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"];
|
||||
if (configuredRoot !== undefined && configuredRoot.trim().length > 0) {
|
||||
return resolveRulesComponentRoot(resolve(configuredRoot));
|
||||
}
|
||||
|
||||
const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl)));
|
||||
if (discoveredRoot !== null) {
|
||||
return resolveRulesComponentRoot(discoveredRoot);
|
||||
}
|
||||
|
||||
return fileURLToPath(new URL("../../..", moduleUrl));
|
||||
}
|
||||
|
||||
function findNearestPluginRoot(startDirectory: string): string | null {
|
||||
let currentDirectory = resolve(startDirectory);
|
||||
while (true) {
|
||||
if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return null;
|
||||
}
|
||||
currentDirectory = parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRulesComponentRoot(pluginRoot: string): string {
|
||||
const componentRoot = join(pluginRoot, "components", "rules");
|
||||
return isDirectory(componentRoot) ? componentRoot : pluginRoot;
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export type RuleSource =
|
||||
| "AGENTS.md"
|
||||
| "CLAUDE.md"
|
||||
| "CONTEXT.md"
|
||||
| "plugin-bundled"
|
||||
| "~/.omo/rules"
|
||||
| "~/.opencode/rules"
|
||||
| "~/.claude/rules"
|
||||
|
||||
Reference in New Issue
Block a user