diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..45360d4ec91 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -72,10 +72,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)) + } } } @@ -91,13 +93,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 4cdb549096a..71b107f2c90 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -2,7 +2,7 @@ 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 +18,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"] @@ -40,7 +41,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") - function truthy(key: string) { + export function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" } @@ -52,3 +53,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 Flag.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 fff90808864..e85216a58f2 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" @@ -18,6 +19,19 @@ import PROMPT_CODEX_INSTRUCTIONS 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 { + 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()] @@ -80,11 +94,14 @@ export namespace SystemPrompt { const config = await Config.get() const paths = new Set() - 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 + } } } @@ -115,7 +132,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 86cadca5d81..65c3edcf040 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1374,3 +1374,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 + } + } + }) +})