diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2f1cba8a0548..7efcbb96e97f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -86,7 +86,9 @@ export namespace Config { } // Global user config overrides remote config - result = mergeConfigConcatArrays(result, await global()) + if (!Flag.OPENCODE_DISABLE_GLOBAL_CONFIG) { + result = mergeConfigConcatArrays(result, await global()) + } // Custom config path overrides global if (Flag.OPENCODE_CONFIG) { @@ -115,7 +117,8 @@ export namespace Config { result.plugin = result.plugin || [] const directories = [ - Global.Path.config, + // Only scan global config directory when global config is enabled + ...(!Flag.OPENCODE_DISABLE_GLOBAL_CONFIG ? [Global.Path.config] : []), // Only scan project .opencode/ directories when project discovery is enabled ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? await Array.fromAsync( @@ -126,14 +129,16 @@ export namespace Config { }), ) : []), - // Always scan ~/.opencode/ (user home directory) - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: Global.Path.home, - stop: Global.Path.home, - }), - )), + // Only scan ~/.opencode/ (user home directory) when global config is enabled + ...(!Flag.OPENCODE_DISABLE_GLOBAL_CONFIG + ? await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + }), + ) + : []), ] if (Flag.OPENCODE_CONFIG_DIR) { diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 9084bf444362..bfd446200bf8 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -24,6 +24,7 @@ export namespace Flag { 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 declare const OPENCODE_DISABLE_GLOBAL_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"] @@ -67,6 +68,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", { configurable: false, }) +// Dynamic getter for OPENCODE_DISABLE_GLOBAL_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_GLOBAL_CONFIG", { + get() { + return truthy("OPENCODE_DISABLE_GLOBAL_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 diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 723439a3fdb2..ca8653667dbb 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -81,10 +81,12 @@ export namespace InstructionPrompt { } } - for (const file of globalFiles()) { - if (await Bun.file(file).exists()) { - paths.add(path.resolve(file)) - break + if (!Flag.OPENCODE_DISABLE_GLOBAL_CONFIG) { + for (const file of globalFiles()) { + if (await Bun.file(file).exists()) { + paths.add(path.resolve(file)) + break + } } } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index d34a086fe441..815969bfaf60 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,16 +1,44 @@ 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" +import os from "os" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" +import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" 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 { + 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()] + return [] + } + export function instructions() { return PROMPT_CODEX.trim() } @@ -24,11 +52,10 @@ export namespace SystemPrompt { return [PROMPT_ANTHROPIC_WITHOUT_TODO] } - export async function environment(model: Provider.Model) { + export async function environment() { const project = Instance.project return [ [ - `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `Here is some useful information about the environment you are running in:`, ``, ` Working directory: ${Instance.directory}`, @@ -49,4 +76,92 @@ export namespace SystemPrompt { ].join("\n"), ] } + + const LOCAL_RULE_FILES = [ + "AGENTS.md", + "CLAUDE.md", + "CONTEXT.md", // deprecated + ] + const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")] + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { + GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) + } + + if (Flag.OPENCODE_CONFIG_DIR) { + GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + } + + export async function custom() { + const config = await Config.get() + const paths = new Set() + + // 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 + } + } + } + + // Only scan global rule files when global config is enabled + if (!Flag.OPENCODE_DISABLE_GLOBAL_CONFIG) { + for (const globalRuleFile of GLOBAL_RULE_FILES) { + if (await Bun.file(globalRuleFile).exists()) { + paths.add(globalRuleFile) + break + } + } + } + + // Always load AGENTS.md from OPENCODE_CONFIG_DIR if set (explicit override) + if (Flag.OPENCODE_CONFIG_DIR) { + const configDirAgents = path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md") + if (await Bun.file(configDirAgents).exists()) { + paths.add(configDirAgents) + } + } + + const urls: string[] = [] + if (config.instructions) { + for (let instruction of config.instructions) { + if (instruction.startsWith("https://") || instruction.startsWith("http://")) { + urls.push(instruction) + continue + } + if (instruction.startsWith("~/")) { + instruction = path.join(os.homedir(), instruction.slice(2)) + } + let matches: string[] = [] + if (path.isAbsolute(instruction)) { + matches = await Array.fromAsync( + new Bun.Glob(path.basename(instruction)).scan({ + cwd: path.dirname(instruction), + absolute: true, + onlyFiles: true, + }), + ).catch(() => []) + } else { + matches = await resolveRelativeInstruction(instruction) + } + matches.forEach((path) => paths.add(path)) + } + } + + const foundFiles = Array.from(paths).map((p) => + Bun.file(p) + .text() + .catch(() => "") + .then((x) => "Instructions from: " + p + "\n" + x), + ) + const foundUrls = urls.map((url) => + fetch(url, { signal: AbortSignal.timeout(5000) }) + .then((res) => (res.ok ? res.text() : "")) + .catch(() => "") + .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), + ) + return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean)) + } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1752e22e01f6..de2ab029d430 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" @@ -1688,3 +1689,196 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { } }) }) + +describe("OPENCODE_DISABLE_GLOBAL_CONFIG", () => { + test("skips global config files when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = "true" + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const directories = await Config.directories() + // Global config directory should NOT be in directories list + const hasGlobalConfig = directories.some((d) => d === Global.Path.config) + expect(hasGlobalConfig).toBe(false) + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = originalEnv + } + } + }) + + test("skips ~/.opencode/ directories when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = "true" + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const directories = await Config.directories() + // Home .opencode directory should NOT be in directories list + const hasHomeOpencode = directories.some((d) => d === path.join(Global.Path.home, ".opencode")) + expect(hasHomeOpencode).toBe(false) + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = originalEnv + } + } + }) + + test("still loads project config when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = "true" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a project config that should still 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 still be loaded + expect(config.model).toBe("project/model") + expect(config.username).toBe("project-user") + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = originalEnv + } + } + }) + + test("OPENCODE_CONFIG_DIR still works when flag is set", async () => { + const originalDisable = process.env["OPENCODE_DISABLE_GLOBAL_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() + + process.env["OPENCODE_DISABLE_GLOBAL_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 even with global config disabled + expect(config.model).toBe("configdir/model") + }, + }) + } finally { + if (originalDisable === undefined) { + delete process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = originalDisable + } + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + } + }) + + test("both flags can be combined to use only OPENCODE_CONFIG_DIR", async () => { + const originalDisableGlobal = process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + const originalDisableProject = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + + try { + await using configDirTmp = await tmpdir({ + init: async (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 project config that 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_GLOBAL_CONFIG"] = "true" + 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 only load from OPENCODE_CONFIG_DIR + expect(config.model).toBe("configdir/model") + }, + }) + } finally { + if (originalDisableGlobal === undefined) { + delete process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_GLOBAL_CONFIG"] = originalDisableGlobal + } + if (originalDisableProject === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisableProject + } + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + } + }) +})