feat: add OPENCODE_DISABLE_PROJECT_CONFIG env var (#8093)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user