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:
YeonGyu-Kim
2026-05-27 14:54:20 +09:00
parent db00bb3d42
commit 8d2e6bea62
12 changed files with 688 additions and 181 deletions
@@ -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"