From 1c49ade7924f95e715f27325fcd3cd05bf06d6c3 Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Thu, 2 Apr 2026 12:48:07 -0700 Subject: [PATCH] feat(sdk): add settingSources to control which config sources are loaded When using the SDK to build specialized agents, all config sources (global, project, remote, managed) are loaded automatically. This can pollute agent behavior with irrelevant settings. Add settingSources parameter to ServerOptions and TuiOptions that accepts an array of source categories: global, project, remote, managed. Default behavior unchanged (all sources loaded). Setting to empty array loads only programmatic config. Also gates AGENTS.md instruction loading by the same source filter. --- packages/opencode/src/config/config.ts | 35 ++- packages/opencode/src/config/paths.ts | 36 ++- packages/opencode/src/config/tui.ts | 2 +- packages/opencode/src/flag/flag.ts | 12 + packages/opencode/src/session/instruction.ts | 35 ++- packages/opencode/test/config/config.test.ts | 277 ++++++++++++++++++ .../opencode/test/session/instruction.test.ts | 148 ++++++++++ packages/sdk/js/src/server.ts | 10 + packages/sdk/js/src/v2/server.ts | 10 + 9 files changed, 525 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 850bcc28bcd9..b1548a7deae1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -5,7 +5,7 @@ import os from "os" import { Process } from "../util/process" import z from "zod" import { ModelsDev } from "../provider/models" -import { mergeDeep, pipe, unique } from "remeda" +import { mergeDeep, pipe } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/util/error" @@ -1257,6 +1257,13 @@ export namespace Config { }) const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) { + const settings = (() => { + const raw = Flag.OPENCODE_SETTINGS_SOURCES + if (raw === undefined) return undefined + if (raw === "") return [] as string[] + return raw.split(",") + })() + const enabled = (s: string) => !settings || settings.includes(s) const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} @@ -1288,6 +1295,7 @@ export namespace Config { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") process.env[value.key] = value.token + if (!enabled("remote")) continue log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) if (!response.ok) { @@ -1306,15 +1314,17 @@ export namespace Config { } } - const global = yield* getGlobal() - merge(Global.Path.config, global, "global") + if (enabled("global")) { + const global = yield* getGlobal() + merge(Global.Path.config, global, "global") + } if (Flag.OPENCODE_CONFIG) { merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG && enabled("project")) { for (const file of yield* Effect.promise(() => ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), )) { @@ -1326,7 +1336,16 @@ export namespace Config { result.mode = result.mode || {} result.plugin = result.plugin || [] - const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) + const tagged = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree)) + const seen = new Set() + const directories: string[] = [] + + for (const entry of tagged) { + if (seen.has(entry.path)) continue + seen.add(entry.path) + if (entry.source !== "always" && !enabled(entry.source)) continue + directories.push(entry.path) + } if (Flag.OPENCODE_CONFIG_DIR) { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) @@ -1334,7 +1353,7 @@ export namespace Config { const deps: Promise[] = [] - for (const dir of unique(directories)) { + for (const dir of directories) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) @@ -1391,7 +1410,7 @@ export namespace Config { dir: path.dirname(source), source, }) - merge(source, next, "global") + if (enabled("remote")) merge(source, next, "global") } }).pipe( Effect.catch((err) => { @@ -1403,7 +1422,7 @@ export namespace Config { ) } - if (existsSync(managedDir)) { + if (enabled("managed") && existsSync(managedDir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(managedDir, file) merge(source, yield* loadFile(source), "global") diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 82ccf3945fcd..1a203dbd1bfc 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -14,24 +14,28 @@ export namespace ConfigPaths { export async function directories(directory: string, worktree: string) { return [ - Global.Path.config, + { path: Global.Path.config, source: "global" as const }, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG - ? await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: directory, - stop: worktree, - }), - ) + ? ( + await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: directory, + stop: worktree, + }), + ) + ).map((item) => ({ path: item, source: "project" as const })) : []), - ...(await Array.fromAsync( - Filesystem.up({ - targets: [".opencode"], - start: Global.Path.home, - stop: Global.Path.home, - }), - )), - ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), + ...( + await Array.fromAsync( + Filesystem.up({ + targets: [".opencode"], + start: Global.Path.home, + stop: Global.Path.home, + }), + ) + ).map((item) => ({ path: item, source: "global" as const })), + ...(Flag.OPENCODE_CONFIG_DIR ? [{ path: Flag.OPENCODE_CONFIG_DIR, source: "always" as const }] : []), ] } diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index adfb3c781069..8464695ce19d 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -72,7 +72,7 @@ export namespace TuiConfig { let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree) - const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree) + const directories = (await ConfigPaths.directories(Instance.directory, Instance.worktree)).map((item) => item.path) const custom = customPath() const managed = Config.managedConfigDir() await migrateTuiConfig({ directories, custom, managed }) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..f38681d2ad96 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -19,6 +19,7 @@ export namespace Flag { export declare const OPENCODE_CONFIG_DIR: string | undefined export declare const OPENCODE_PLUGIN_META_FILE: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] + export declare const OPENCODE_SETTINGS_SOURCES: string | undefined export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") @@ -152,3 +153,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) + +// Dynamic getter for OPENCODE_SETTINGS_SOURCES +// This must be evaluated at access time, not module load time, +// because tests and external tooling set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_SETTINGS_SOURCES", { + get() { + return process.env["OPENCODE_SETTINGS_SOURCES"] + }, + enumerable: true, + configurable: false, +}) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 02a536edd864..a4413b9cc566 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -22,18 +22,6 @@ const FILES = [ "CONTEXT.md", // deprecated ] -function globalFiles() { - const files = [] - if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) - } - files.push(path.join(Global.Path.config, "AGENTS.md")) - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - return files -} - function extract(messages: MessageV2.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -122,9 +110,11 @@ export namespace Instruction { const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { const config = yield* cfg.get() const paths = new Set() + const raw = Flag.OPENCODE_SETTINGS_SOURCES + const sources = raw === undefined ? undefined : raw === "" ? ([] as string[]) : raw.split(",") // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG && (!sources || sources.includes("project"))) { for (const file of FILES) { const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree) if (matches.length > 0) { @@ -134,10 +124,25 @@ export namespace Instruction { } } - for (const file of globalFiles()) { + let found = false + if (Flag.OPENCODE_CONFIG_DIR) { + const file = path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md") + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + found = true + } + } + if (!found && (!sources || sources.includes("global"))) { + const file = path.join(Global.Path.config, "AGENTS.md") + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + found = true + } + } + if (!found && (!sources || sources.includes("global")) && !Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { + const file = path.join(os.homedir(), ".claude", "CLAUDE.md") if (yield* fs.existsSafe(file)) { paths.add(path.resolve(file)) - break } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c631360b620..daa52107a79e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2346,3 +2346,280 @@ test("parseManagedPlist handles empty config", async () => { ) expect(config.$schema).toBe("https://opencode.ai/config.json") }) + +describe("OPENCODE_SETTINGS_SOURCES", () => { + let originalSources: string | undefined + let originalConfigDir: string | undefined + let originalConfigContent: string | undefined + let originalDisableProject: string | undefined + let originalGlobalConfig: string + + beforeEach(() => { + originalSources = process.env["OPENCODE_SETTINGS_SOURCES"] + originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + originalConfigContent = process.env["OPENCODE_CONFIG_CONTENT"] + originalDisableProject = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + originalGlobalConfig = Global.Path.config + }) + + afterEach(async () => { + if (originalSources === undefined) delete process.env["OPENCODE_SETTINGS_SOURCES"] + else process.env["OPENCODE_SETTINGS_SOURCES"] = originalSources + if (originalConfigDir === undefined) delete process.env["OPENCODE_CONFIG_DIR"] + else process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + if (originalConfigContent === undefined) delete process.env["OPENCODE_CONFIG_CONTENT"] + else process.env["OPENCODE_CONFIG_CONTENT"] = originalConfigContent + if (originalDisableProject === undefined) delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + else process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisableProject + ;(Global.Path as { config: string }).config = originalGlobalConfig + await Config.invalidate() + }) + + async function writeSources(globalDir: string, dir: string) { + await writeConfig(globalDir, { + $schema: "https://opencode.ai/config.json", + username: "global-user", + }) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "project/model", + }) + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await writeConfig( + path.join(dir, ".opencode"), + { + $schema: "https://opencode.ai/config.json", + share: "disabled", + }, + "opencode.json", + ) + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + disabled_providers: ["openai"], + }) + } + + test("loads all sources when OPENCODE_SETTINGS_SOURCES is not set", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + delete process.env["OPENCODE_SETTINGS_SOURCES"] + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("global-user") + expect(config.model).toBe("project/model") + expect(config.share).toBe("disabled") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(true) + }, + }) + }) + + test("loads no filesystem sources when OPENCODE_SETTINGS_SOURCES is empty string", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "" + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).not.toBe("global-user") + expect(config.model).not.toBe("project/model") + expect(config.share).not.toBe("disabled") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(false) + }, + }) + }) + + test("loads only global sources when OPENCODE_SETTINGS_SOURCES is 'global'", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "global" + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("global-user") + expect(config.model).not.toBe("project/model") + expect(config.share).not.toBe("disabled") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(false) + }, + }) + }) + + test("loads only project sources when OPENCODE_SETTINGS_SOURCES is 'project'", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "project" + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).not.toBe("global-user") + expect(config.model).toBe("project/model") + expect(config.share).toBe("disabled") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(false) + }, + }) + }) + + test("loads multiple sources when comma-separated", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "global,project" + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("global-user") + expect(config.model).toBe("project/model") + expect(config.share).toBe("disabled") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(false) + }, + }) + }) + + test("OPENCODE_CONFIG_DIR always loaded regardless of settingSources", async () => { + await using globalTmp = await tmpdir() + await using profileTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "" + process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await writeConfig(profileTmp.path, { + $schema: "https://opencode.ai/config.json", + model: "configdir/model", + }) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("configdir/model") + }, + }) + }) + + test("OPENCODE_CONFIG_CONTENT always loaded regardless of settingSources", async () => { + await using tmp = await tmpdir() + process.env["OPENCODE_SETTINGS_SOURCES"] = "" + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "content/model", + }) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("content/model") + }, + }) + }) + + test("managed sources gated by settingSources", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir() + ;(Global.Path as { config: string }).config = globalTmp.path + await writeConfig(globalTmp.path, { + $schema: "https://opencode.ai/config.json", + username: "global-user", + }) + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + disabled_providers: ["openai"], + }) + + process.env["OPENCODE_SETTINGS_SOURCES"] = "global" + await Config.invalidate() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("global-user") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(false) + }, + }) + + process.env["OPENCODE_SETTINGS_SOURCES"] = "managed" + await Config.invalidate() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).not.toBe("global-user") + expect(config.disabled_providers?.includes("openai") ?? false).toBe(true) + }, + }) + }) + + test("skips project .opencode/ directories when project not in settingSources", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const commandDir = path.join(dir, ".opencode", "command") + await fs.mkdir(commandDir, { recursive: true }) + await Filesystem.write(path.join(commandDir, "test.md"), "# Test\nBody") + }, + }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "global" + ;(Global.Path as { config: string }).config = globalTmp.path + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const directories = await Config.directories() + const config = await Config.get() + expect(directories.some((item) => item.startsWith(tmp.path))).toBe(false) + expect(config.command?.test).toBeUndefined() + }, + }) + }) + + test("coexists with OPENCODE_DISABLE_PROJECT_CONFIG", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + process.env["OPENCODE_SETTINGS_SOURCES"] = "global,project" + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + ;(Global.Path as { config: string }).config = globalTmp.path + await writeSources(globalTmp.path, tmp.path) + await Config.invalidate() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const directories = await Config.directories() + const config = await Config.get() + expect(config.username).toBe("global-user") + expect(config.model).not.toBe("project/model") + expect(config.share).not.toBe("disabled") + expect(directories.some((item) => item.startsWith(tmp.path))).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index a8c25c6f0e10..fef9fad22f19 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -284,3 +284,151 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { } }) }) + +describe("Instruction.systemPaths OPENCODE_SETTINGS_SOURCES", () => { + let originalSources: string | undefined + let originalConfigDir: string | undefined + let originalGlobalConfig: string + + beforeEach(() => { + originalSources = process.env["OPENCODE_SETTINGS_SOURCES"] + originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + originalGlobalConfig = Global.Path.config + }) + + afterEach(() => { + if (originalSources === undefined) { + delete process.env["OPENCODE_SETTINGS_SOURCES"] + } else { + process.env["OPENCODE_SETTINGS_SOURCES"] = originalSources + } + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + ;(Global.Path as { config: string }).config = originalGlobalConfig + }) + + test("skips project AGENTS.md when project not in settingSources", async () => { + await using globalTmp = await tmpdir() + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") + }, + }) + + process.env["OPENCODE_SETTINGS_SOURCES"] = "global" + ;(Global.Path as { config: string }).config = globalTmp.path + + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(false) + }, + }) + }) + + test("skips global AGENTS.md when global not in settingSources", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + process.env["OPENCODE_SETTINGS_SOURCES"] = "project" + ;(Global.Path as { config: string }).config = globalTmp.path + + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + }, + }) + }) + + test("loads both project and global when both in settingSources", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") + }, + }) + + process.env["OPENCODE_SETTINGS_SOURCES"] = "project,global" + ;(Global.Path as { config: string }).config = globalTmp.path + + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }, + }) + }) + + test("OPENCODE_CONFIG_DIR AGENTS.md always loaded", async () => { + await using profileTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions") + }, + }) + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") + }, + }) + + process.env["OPENCODE_SETTINGS_SOURCES"] = "" + process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path + ;(Global.Path as { config: string }).config = globalTmp.path + + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + }, + }) + }) + + test("loads all when OPENCODE_SETTINGS_SOURCES is not set", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") + }, + }) + + delete process.env["OPENCODE_SETTINGS_SOURCES"] + ;(Global.Path as { config: string }).config = globalTmp.path + + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await Instruction.systemPaths() + expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index 174131ccfd5d..832064fd13c3 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -1,12 +1,15 @@ import { spawn } from "node:child_process" import { type Config } from "./gen/types.gen.js" +export type SettingSource = "global" | "project" | "remote" | "managed" + export type ServerOptions = { hostname?: string port?: number signal?: AbortSignal timeout?: number config?: Config + settingSources?: SettingSource[] } export type TuiOptions = { @@ -16,6 +19,7 @@ export type TuiOptions = { agent?: string signal?: AbortSignal config?: Config + settingSources?: SettingSource[] } export async function createOpencodeServer(options?: ServerOptions) { @@ -36,6 +40,9 @@ export async function createOpencodeServer(options?: ServerOptions) { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + ...(options.settingSources !== undefined && { + OPENCODE_SETTINGS_SOURCES: options.settingSources.join(","), + }), }, }) @@ -112,6 +119,9 @@ export function createOpencodeTui(options?: TuiOptions) { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + ...(options?.settingSources !== undefined && { + OPENCODE_SETTINGS_SOURCES: options.settingSources.join(","), + }), }, }) diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index 174131ccfd5d..832064fd13c3 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -1,12 +1,15 @@ import { spawn } from "node:child_process" import { type Config } from "./gen/types.gen.js" +export type SettingSource = "global" | "project" | "remote" | "managed" + export type ServerOptions = { hostname?: string port?: number signal?: AbortSignal timeout?: number config?: Config + settingSources?: SettingSource[] } export type TuiOptions = { @@ -16,6 +19,7 @@ export type TuiOptions = { agent?: string signal?: AbortSignal config?: Config + settingSources?: SettingSource[] } export async function createOpencodeServer(options?: ServerOptions) { @@ -36,6 +40,9 @@ export async function createOpencodeServer(options?: ServerOptions) { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + ...(options.settingSources !== undefined && { + OPENCODE_SETTINGS_SOURCES: options.settingSources.join(","), + }), }, }) @@ -112,6 +119,9 @@ export function createOpencodeTui(options?: TuiOptions) { env: { ...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + ...(options?.settingSources !== undefined && { + OPENCODE_SETTINGS_SOURCES: options.settingSources.join(","), + }), }, })