summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKenny <[email protected]>2026-01-21 00:36:42 -0500
committerGitHub <[email protected]>2026-01-20 23:36:42 -0600
commita18ae2c8b7b29f89aa4bbe56d14b786f41c9f4f5 (patch)
tree7b68c4c1135a908916e56bbde95924f3d6fe2a9b /packages
parentc9ea9668055fee9675412e621f96e815f4ec4e1d (diff)
downloadopencode-a18ae2c8b7b29f89aa4bbe56d14b786f41c9f4f5.tar.gz
opencode-a18ae2c8b7b29f89aa4bbe56d14b786f41c9f4f5.zip
feat: add OPENCODE_DISABLE_PROJECT_CONFIG env var (#8093)
Co-authored-by: Aiden Cline <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/config/config.ts28
-rw-r--r--packages/opencode/src/flag/flag.ts35
-rw-r--r--packages/opencode/src/session/system.ts29
-rw-r--r--packages/opencode/test/config/config.test.ts202
4 files changed, 271 insertions, 23 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index b2142e29b..020e626cb 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -80,10 +80,12 @@ export namespace Config {
}
// Project config has highest precedence (overrides global and remote)
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
- for (const resolved of found.toReversed()) {
- result = mergeConfigConcatArrays(result, await loadFile(resolved))
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const file of ["opencode.jsonc", "opencode.json"]) {
+ const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
+ for (const resolved of found.toReversed()) {
+ result = mergeConfigConcatArrays(result, await loadFile(resolved))
+ }
}
}
@@ -99,13 +101,17 @@ export namespace Config {
const directories = [
Global.Path.config,
- ...(await Array.fromAsync(
- Filesystem.up({
- targets: [".opencode"],
- start: Instance.directory,
- stop: Instance.worktree,
- }),
- )),
+ // Only scan project .opencode/ directories when project discovery is enabled
+ ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? await Array.fromAsync(
+ Filesystem.up({
+ targets: [".opencode"],
+ start: Instance.directory,
+ stop: Instance.worktree,
+ }),
+ )
+ : []),
+ // Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 63b1ac7e5..d106c2d86 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -1,8 +1,13 @@
+function truthy(key: string) {
+ const value = process.env[key]?.toLowerCase()
+ return value === "true" || value === "1"
+}
+
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
- export const OPENCODE_CONFIG_DIR = process.env["OPENCODE_CONFIG_DIR"]
+ export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
@@ -18,6 +23,7 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
+ export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
@@ -41,11 +47,6 @@ export namespace Flag {
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
- function truthy(key: string) {
- const value = process.env[key]?.toLowerCase()
- return value === "true" || value === "1"
- }
-
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -53,3 +54,25 @@ export namespace Flag {
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
}
+
+// Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG
+// This must be evaluated at access time, not module load time,
+// because external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
+ get() {
+ return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
+ },
+ enumerable: true,
+ configurable: false,
+})
+
+// Dynamic getter for OPENCODE_CONFIG_DIR
+// This must be evaluated at access time, not module load time,
+// because external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
+ get() {
+ return process.env["OPENCODE_CONFIG_DIR"]
+ },
+ enumerable: true,
+ configurable: false,
+})
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index f0e2d96b7..1f0fc3d0a 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
+import { Log } from "../util/log"
import { Instance } from "../project/instance"
import path from "path"
@@ -17,6 +18,19 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag"
+const log = Log.create({ service: "system-prompt" })
+
+async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
+ }
+ if (!Flag.OPENCODE_CONFIG_DIR) {
+ log.warn(`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`)
+ return []
+ }
+ return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
+}
+
export namespace SystemPrompt {
export function header(providerID: string) {
if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()]
@@ -79,11 +93,14 @@ export namespace SystemPrompt {
const config = await Config.get()
const paths = new Set<string>()
- for (const localRuleFile of LOCAL_RULE_FILES) {
- const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
- if (matches.length > 0) {
- matches.forEach((path) => paths.add(path))
- break
+ // Only scan local rule files when project discovery is enabled
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const localRuleFile of LOCAL_RULE_FILES) {
+ const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
+ if (matches.length > 0) {
+ matches.forEach((path) => paths.add(path))
+ break
+ }
}
}
@@ -114,7 +131,7 @@ export namespace SystemPrompt {
}),
).catch(() => [])
} else {
- matches = await Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
+ matches = await resolveRelativeInstruction(instruction)
}
matches.forEach((path) => paths.add(path))
}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 0463d29d7..1dbfe1f82 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -1412,3 +1412,205 @@ describe("deduplicatePlugins", () => {
})
})
})
+
+describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
+ test("skips project config files when flag is set", async () => {
+ const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+
+ try {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create a project config that would normally be loaded
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ model: "project/model",
+ username: "project-user",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ // Project config should NOT be loaded - model should be default, not "project/model"
+ expect(config.model).not.toBe("project/model")
+ expect(config.username).not.toBe("project-user")
+ },
+ })
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ } else {
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+ }
+ }
+ })
+
+ test("skips project .opencode/ directories when flag is set", async () => {
+ const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+
+ try {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create a .opencode directory with a command
+ const opencodeDir = path.join(dir, ".opencode", "command")
+ await fs.mkdir(opencodeDir, { recursive: true })
+ await Bun.write(
+ path.join(opencodeDir, "test-cmd.md"),
+ "# Test Command\nThis is a test command.",
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const directories = await Config.directories()
+ // Project .opencode should NOT be in directories list
+ const hasProjectOpencode = directories.some(d => d.startsWith(tmp.path))
+ expect(hasProjectOpencode).toBe(false)
+ },
+ })
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ } else {
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+ }
+ }
+ })
+
+ test("still loads global config when flag is set", async () => {
+ const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+
+ try {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Should still get default config (from global or defaults)
+ const config = await Config.get()
+ expect(config).toBeDefined()
+ expect(config.username).toBeDefined()
+ },
+ })
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ } else {
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
+ }
+ }
+ })
+
+ test("skips relative instructions with warning when flag is set but no config dir", async () => {
+ const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
+
+ try {
+ // Ensure no config dir is set
+ delete process.env["OPENCODE_CONFIG_DIR"]
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ // Create a config with relative instruction path
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ instructions: ["./CUSTOM.md"],
+ }),
+ )
+ // Create the instruction file (should be skipped)
+ await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // The relative instruction should be skipped without error
+ // We're mainly verifying this doesn't throw and the config loads
+ const config = await Config.get()
+ expect(config).toBeDefined()
+ // The instruction should have been skipped (warning logged)
+ // We can't easily test the warning was logged, but we verify
+ // the relative path didn't cause an error
+ },
+ })
+ } finally {
+ if (originalDisable === undefined) {
+ delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ } else {
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
+ }
+ if (originalConfigDir === undefined) {
+ delete process.env["OPENCODE_CONFIG_DIR"]
+ } else {
+ process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
+ }
+ }
+ })
+
+ test("OPENCODE_CONFIG_DIR still works when flag is set", async () => {
+ const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
+
+ try {
+ await using configDirTmp = await tmpdir({
+ init: async (dir) => {
+ // Create config in the custom config dir
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ model: "configdir/model",
+ }),
+ )
+ },
+ })
+
+ await using projectTmp = await tmpdir({
+ init: async (dir) => {
+ // Create config in project (should be ignored)
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ model: "project/model",
+ }),
+ )
+ },
+ })
+
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
+ process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
+
+ await Instance.provide({
+ directory: projectTmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ // Should load from OPENCODE_CONFIG_DIR, not project
+ expect(config.model).toBe("configdir/model")
+ },
+ })
+ } finally {
+ if (originalDisable === undefined) {
+ delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
+ } else {
+ process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
+ }
+ if (originalConfigDir === undefined) {
+ delete process.env["OPENCODE_CONFIG_DIR"]
+ } else {
+ process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
+ }
+ }
+ })
+})