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"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { configFromEnvironment } from "../src/config.js";
|
||||
import { SOURCE_PRIORITY } from "../src/rules/constants.js";
|
||||
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
|
||||
import { resolvePluginRulesRoot } from "../src/rules/plugin-root.js";
|
||||
import type { RuleCandidate } from "../src/rules/types.js";
|
||||
|
||||
const projectRoot = "/tmp/codex-rules-bundled-priority";
|
||||
const bundledPath = join(projectRoot, "bundled-rules", "hephaestus.md");
|
||||
const homePath = join(projectRoot, "home", ".opencode", "rules", "hephaestus.md");
|
||||
const bundledBody = "Bundled baseline discipline.";
|
||||
const homeBody = "Home baseline discipline override.";
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function globalCandidate(source: "plugin-bundled" | "~/.opencode/rules", path: string): RuleCandidate {
|
||||
return {
|
||||
path,
|
||||
realPath: path,
|
||||
source,
|
||||
distance: 9999,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: source === "plugin-bundled" ? "bundled-rules/hephaestus.md" : ".opencode/rules/hephaestus.md",
|
||||
};
|
||||
}
|
||||
|
||||
function ruleMarkdown(body: string): string {
|
||||
return [
|
||||
"---",
|
||||
"description: OMO Hephaestus baseline discipline for Codex",
|
||||
"alwaysApply: true",
|
||||
"---",
|
||||
"",
|
||||
body,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("plugin bundled rule priority", () => {
|
||||
it("#given bundled source explicitly enabled then disabled #when parsing env #then no sources remain enabled", () => {
|
||||
// given / when
|
||||
const config = configFromEnvironment({
|
||||
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
|
||||
CODEX_RULES_DISABLE_BUNDLED: "1",
|
||||
});
|
||||
|
||||
// then
|
||||
expect(config.enabledSources).toEqual([]);
|
||||
});
|
||||
|
||||
it("#given source priorities #when comparing user-home and bundled rules #then bundled has lower priority", () => {
|
||||
// given / when / then
|
||||
expect(SOURCE_PRIORITY.get("~/.opencode/rules")).toBe(101);
|
||||
expect(SOURCE_PRIORITY.get("plugin-bundled")).toBe(200);
|
||||
});
|
||||
|
||||
it("#given user-home and bundled rules share a description #when formatting static rules #then user-home wins", () => {
|
||||
// given
|
||||
const bundledCandidate = globalCandidate("plugin-bundled", bundledPath);
|
||||
const homeCandidate = globalCandidate("~/.opencode/rules", homePath);
|
||||
const deps = {
|
||||
findProjectRoot: () => projectRoot,
|
||||
findCandidates: () => [bundledCandidate, homeCandidate],
|
||||
readFile: (path: string) => {
|
||||
if (path === bundledPath) return ruleMarkdown(bundledBody);
|
||||
if (path === homePath) return ruleMarkdown(homeBody);
|
||||
return null;
|
||||
},
|
||||
} satisfies EngineDeps;
|
||||
const engine = createEngine(defaultConfig(), deps);
|
||||
|
||||
// when
|
||||
const loaded = engine.loadStaticRules(projectRoot);
|
||||
const formatted = engine.formatStatic(loaded.rules);
|
||||
|
||||
// then
|
||||
expect(formatted).toContain(homePath);
|
||||
expect(formatted).toContain(homeBody);
|
||||
expect(formatted).not.toContain(bundledPath);
|
||||
expect(formatted).not.toContain(bundledBody);
|
||||
});
|
||||
|
||||
it("#given aggregate plugin root #when resolving rules root #then components rules directory is selected", () => {
|
||||
// given
|
||||
const aggregateRoot = mkdtempSync(join(tmpdir(), "codex-rules-aggregate-plugin-"));
|
||||
const componentRoot = join(aggregateRoot, "components", "rules");
|
||||
tempDirectories.push(aggregateRoot);
|
||||
mkdirSync(join(aggregateRoot, ".codex-plugin"), { recursive: true });
|
||||
mkdirSync(componentRoot, { recursive: true });
|
||||
writeFileSync(join(aggregateRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "omo" }));
|
||||
|
||||
// when
|
||||
const resolvedRoot = resolvePluginRulesRoot(aggregateRoot);
|
||||
|
||||
// then
|
||||
expect(resolvedRoot).toBe(componentRoot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type CodexPostCompactInput,
|
||||
type CodexSessionStartInput,
|
||||
runPostCompactHook,
|
||||
runSessionStartHook,
|
||||
runUserPromptSubmitHook,
|
||||
} from "../src/codex-hook.js";
|
||||
import { createRuleDiscoveryCache, findRuleCandidates } from "../src/rules/finder.js";
|
||||
|
||||
interface FixtureOptions {
|
||||
readonly writeProjectDuplicate?: boolean;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
readonly pluginRoot: string;
|
||||
readonly pluginData: string;
|
||||
readonly bundledRulePath: string;
|
||||
readonly projectRulePath: string;
|
||||
}
|
||||
|
||||
const BUNDLED_ONLY_ENV = {
|
||||
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
|
||||
};
|
||||
|
||||
const PROJECT_AND_BUNDLED_ENV = {
|
||||
CODEX_RULES_ENABLED_SOURCES: ".omo/rules,plugin-bundled",
|
||||
};
|
||||
|
||||
const DISABLED_BUNDLED_ENV = {
|
||||
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
|
||||
CODEX_RULES_DISABLE_BUNDLED: "1",
|
||||
};
|
||||
|
||||
const BUNDLED_BODY = "Bundled craftsman baseline.";
|
||||
const SHARED_BODY = "Always choose the smallest correct change.";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
let originalPluginRoot: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalPluginRoot = process.env["PLUGIN_ROOT"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("PLUGIN_ROOT", originalPluginRoot);
|
||||
for (const directory of tempDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeFixture(options: FixtureOptions = {}): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), "codex-rules-bundled-project-"));
|
||||
const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-bundled-plugin-"));
|
||||
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-bundled-data-"));
|
||||
tempDirectories.push(root, pluginRoot, pluginData);
|
||||
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
||||
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
|
||||
mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true });
|
||||
|
||||
const bundledRulePath = join(pluginRoot, "bundled-rules", "hephaestus.md");
|
||||
const bundledBody = options.writeProjectDuplicate === true ? SHARED_BODY : BUNDLED_BODY;
|
||||
writeFileSync(bundledRulePath, ruleMarkdown(bundledBody));
|
||||
|
||||
const projectRulePath = join(root, ".omo", "rules", "hephaestus.md");
|
||||
if (options.writeProjectDuplicate === true) {
|
||||
writeFileSync(projectRulePath, ruleMarkdown(SHARED_BODY));
|
||||
}
|
||||
|
||||
process.env["PLUGIN_ROOT"] = pluginRoot;
|
||||
return { root, pluginRoot, pluginData, bundledRulePath, projectRulePath };
|
||||
}
|
||||
|
||||
function ruleMarkdown(body: string): string {
|
||||
return ["---", "description: Fixture", "alwaysApply: true", "---", "", body].join("\n");
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[name] = value;
|
||||
}
|
||||
|
||||
function sessionStartInput(root: string): CodexSessionStartInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "SessionStart",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
source: "startup",
|
||||
};
|
||||
}
|
||||
|
||||
function postCompactInput(root: string): CodexPostCompactInput {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-compact",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "PostCompact",
|
||||
model: "gpt-5.5",
|
||||
trigger: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
function userPromptSubmitInput(root: string): Parameters<typeof runUserPromptSubmitHook>[0] {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
turn_id: "turn-1",
|
||||
transcript_path: null,
|
||||
cwd: root,
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
model: "gpt-5.5",
|
||||
permission_mode: "default",
|
||||
prompt: "continue",
|
||||
};
|
||||
}
|
||||
|
||||
function occurrenceCount(value: string, search: string): number {
|
||||
return value.split(search).length - 1;
|
||||
}
|
||||
|
||||
describe("plugin bundled rules", () => {
|
||||
it("#given PLUGIN_ROOT with bundled markdown #when finding candidates #then plugin-bundled source is cached", () => {
|
||||
// given
|
||||
const { pluginRoot } = makeFixture();
|
||||
const cache = createRuleDiscoveryCache();
|
||||
|
||||
// when
|
||||
const candidates = findRuleCandidates({ projectRoot: null, targetFile: null, skipUserHome: true, cache });
|
||||
|
||||
// then
|
||||
expect(candidates.map((candidate) => `${candidate.source}:${candidate.relativePath}`)).toEqual([
|
||||
"plugin-bundled:bundled-rules/hephaestus.md",
|
||||
]);
|
||||
expect(cache.scannedRuleFiles.has(join(pluginRoot, "bundled-rules"))).toBe(true);
|
||||
});
|
||||
|
||||
it("#given alwaysApply bundled rule #when SessionStart runs #then static context includes it", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeFixture();
|
||||
|
||||
// when
|
||||
const output = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: BUNDLED_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(output).toContain('"hookEventName":"SessionStart"');
|
||||
expect(output).toContain(BUNDLED_BODY);
|
||||
});
|
||||
|
||||
it("#given same project and bundled body #when SessionStart runs #then project rule wins", async () => {
|
||||
// given
|
||||
const { root, pluginData, bundledRulePath, projectRulePath } = makeFixture({ writeProjectDuplicate: true });
|
||||
|
||||
// when
|
||||
const output = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: PROJECT_AND_BUNDLED_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(occurrenceCount(output, SHARED_BODY)).toBe(1);
|
||||
expect(output).toContain(projectRulePath);
|
||||
expect(output).not.toContain(bundledRulePath);
|
||||
});
|
||||
|
||||
it("#given bundled rules disabled #when SessionStart runs #then bundled context is suppressed", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeFixture();
|
||||
|
||||
// when
|
||||
const output = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: DISABLED_BUNDLED_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given PostCompact pending flag #when UserPromptSubmit runs #then bundled static context re-injects", async () => {
|
||||
// given
|
||||
const { root, pluginData } = makeFixture();
|
||||
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: BUNDLED_ONLY_ENV,
|
||||
});
|
||||
expect(firstOutput).toContain(BUNDLED_BODY);
|
||||
|
||||
// when
|
||||
const compactOutput = await runPostCompactHook(postCompactInput(root), { pluginDataRoot: pluginData });
|
||||
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root), {
|
||||
pluginDataRoot: pluginData,
|
||||
env: BUNDLED_ONLY_ENV,
|
||||
});
|
||||
|
||||
// then
|
||||
expect(compactOutput).toBe("");
|
||||
expect(output).toContain(BUNDLED_BODY);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,12 @@ describe("findRuleCandidates", () => {
|
||||
const { projectRoot, homeRoot, targetPath } = makeProject();
|
||||
|
||||
// when
|
||||
const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot });
|
||||
const candidates = findRuleCandidates({
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
homeDir: homeRoot,
|
||||
disabledSources: new Set(["plugin-bundled"]),
|
||||
});
|
||||
|
||||
// then
|
||||
expect(candidates.map(candidateSummary)).toEqual([
|
||||
@@ -64,7 +69,7 @@ describe("findRuleCandidates", () => {
|
||||
projectRoot,
|
||||
targetFile: targetPath,
|
||||
homeDir: homeRoot,
|
||||
disabledSources: new Set([".omo/rules", "~/.opencode/rules"]),
|
||||
disabledSources: new Set([".omo/rules", "~/.opencode/rules", "plugin-bundled"]),
|
||||
});
|
||||
|
||||
// then
|
||||
@@ -84,6 +89,7 @@ describe("findRuleCandidates", () => {
|
||||
targetFile: targetPath,
|
||||
homeDir: homeRoot,
|
||||
skipUserHome: true,
|
||||
disabledSources: new Set(["plugin-bundled"]),
|
||||
});
|
||||
|
||||
// then
|
||||
|
||||
Reference in New Issue
Block a user