From fd54f678c47e01efcc9e9310443ceb1f4fdcb130 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Sun, 5 Apr 2026 19:30:16 +0900 Subject: [PATCH] feat(session): load rules from global and project .opencode/rules/ Adds support for loading *.md rule files from: - ~/.opencode/rules/ (global, all projects) - {project}/.opencode/rules/ (project-specific, overrides global by filename) Rules are injected into the system prompt alongside AGENTS.md content. Closes #48 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/session/instruction.ts | 29 +++ .../test/session/instruction-rules.test.ts | 191 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 packages/opencode/test/session/instruction-rules.test.ts diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index fc90093e99fe..d22a640fd11c 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -141,6 +141,35 @@ export namespace Instruction { } } + // Rules files from global and project directories + const globalRulesDir = path.join(Global.Path.home, ".opencode", "rules") + + const globalRuleFiles = yield* fs + .glob("*.md", { cwd: globalRulesDir, absolute: true, include: "file" }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + // Project rules only load when project config is not disabled (trust boundary) + const projectRuleFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : yield* fs + .glob("*.md", { + cwd: path.join(Instance.directory, ".opencode", "rules"), + absolute: true, + include: "file", + }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + + // Project rules override global by filename + const projectFilenames = new Set(projectRuleFiles.map((p) => path.basename(p))) + for (const rule of globalRuleFiles) { + if (!projectFilenames.has(path.basename(rule))) { + paths.add(path.resolve(rule)) + } + } + for (const rule of projectRuleFiles) { + paths.add(path.resolve(rule)) + } + if (config.instructions) { for (const raw of config.instructions) { if (raw.startsWith("https://") || raw.startsWith("http://")) continue diff --git a/packages/opencode/test/session/instruction-rules.test.ts b/packages/opencode/test/session/instruction-rules.test.ts new file mode 100644 index 000000000000..12cd8d9192d5 --- /dev/null +++ b/packages/opencode/test/session/instruction-rules.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instruction } from "../../src/session/instruction" +import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" +import { tmpdir } from "../fixture/fixture" + +describe("Instruction.systemPaths rules loading", () => { + test("loads *.md files from global rules directory", async () => { + await using homeTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Style Rules") + await Bun.write(path.join(dir, ".opencode", "rules", "security.md"), "# Security Rules") + }, + }) + await using projectTmp = await tmpdir() + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = homeTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "style.md"))).toBe(true) + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "security.md"))).toBe(true) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } + }) + + test("loads *.md files from project rules directory", async () => { + await using homeTmp = await tmpdir() + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "local.md"), "# Local Rules") + }, + }) + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = homeTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "local.md"))).toBe(true) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } + }) + + test("project rule with same filename overrides global", async () => { + await using homeTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Global Style") + await Bun.write(path.join(dir, ".opencode", "rules", "unique-global.md"), "# Unique Global") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "style.md"), "# Project Style") + }, + }) + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = homeTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + // Project style.md should be present + expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "style.md"))).toBe(true) + // Global style.md should NOT be present (overridden) + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "style.md"))).toBe(false) + // Global unique-global.md should still be present + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "unique-global.md"))).toBe(true) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } + }) + + test("missing rules directories do not cause errors", async () => { + await using homeTmp = await tmpdir() + await using projectTmp = await tmpdir() + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = homeTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + // Should not throw even when neither rules dir exists + const paths = await Instruction.systemPaths() + expect(paths).toBeInstanceOf(Set) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } + }) + + test("project rules are not loaded when OPENCODE_DISABLE_PROJECT_CONFIG is set", async () => { + await using homeTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "global.md"), "# Global Rule") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "malicious.md"), "# Malicious Rule") + }, + }) + + const originalHome = process.env.OPENCODE_TEST_HOME + const originalDisable = process.env.OPENCODE_DISABLE_PROJECT_CONFIG + process.env.OPENCODE_TEST_HOME = homeTmp.path + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "true" + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + // Global rules should still load + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "global.md"))).toBe(true) + // Project rules must NOT load (trust boundary) + expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "malicious.md"))).toBe(false) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + if (originalDisable === undefined) { + delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + } else { + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = originalDisable + } + } + }) + + test("non-.md files are not loaded from rules directories", async () => { + await using homeTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "valid.md"), "# Valid Rule") + await Bun.write(path.join(dir, ".opencode", "rules", "ignored.txt"), "Not a rule") + await Bun.write(path.join(dir, ".opencode", "rules", "ignored.json"), '{"not": "a rule"}') + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".opencode", "rules", "local.md"), "# Local Rule") + await Bun.write(path.join(dir, ".opencode", "rules", "data.yaml"), "not: a rule") + }, + }) + + const originalHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = homeTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + const allPaths = Array.from(paths) + + // .md files should be loaded + expect(paths.has(path.join(homeTmp.path, ".opencode", "rules", "valid.md"))).toBe(true) + expect(paths.has(path.join(projectTmp.path, ".opencode", "rules", "local.md"))).toBe(true) + + // non-.md files should NOT be loaded + expect(allPaths.some((p) => p.endsWith("ignored.txt"))).toBe(false) + expect(allPaths.some((p) => p.endsWith("ignored.json"))).toBe(false) + expect(allPaths.some((p) => p.endsWith("data.yaml"))).toBe(false) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = originalHome + } + }) +})