Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ const paths = {

export const Path = paths

/**
* `Interface.config` resolves to `Flag.OPENCODE_CONFIG_DIR` when that flag is
* set, replacing the static XDG global config dir entirely rather than
* adding to it (see issue #28658). Callers that scan a single `config`
* directory for global instructions/config files should search this list
* instead of `config` alone, so `OPENCODE_CONFIG_DIR` stays additive: it's
* checked first so it still takes priority, but the real global default is
* never dropped just because an override is set.
*/
export function configDirs(config: string): string[] {
return config === Path.config ? [config] : [config, Path.config]
}

Flock.setGlobal({ state })

await Promise.all([
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/instruction-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ const layer = Layer.effectDiscard(
fs.resolve,
),
)
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const globalPaths = yield* Effect.forEach(Global.configDirs(global.config), (dir) =>
fs.resolve(join(dir, "AGENTS.md")),
)
const paths = Array.dedupe([...globalPaths, ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ describe("global paths", () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})

describe("Global.configDirs", () => {
// Regression coverage for #28658: OPENCODE_CONFIG_DIR replaced the global
// config dir instead of adding to it.
test("checks an override in addition to the real global default", () => {
expect(Global.configDirs("/override")).toEqual(["/override", Global.Path.config])
})

test("prioritizes the override over the real global default", () => {
expect(Global.configDirs("/override")[0]).toBe("/override")
})

test("does not duplicate the path when there is no override", () => {
expect(Global.configDirs(Global.Path.config)).toEqual([Global.Path.config])
})
})
44 changes: 44 additions & 0 deletions packages/core/test/instruction-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,50 @@ describe("InstructionContext", () => {
),
)

it.live("still loads the real global AGENTS.md when OPENCODE_CONFIG_DIR points elsewhere", () =>
// Regression coverage for #28658: OPENCODE_CONFIG_DIR replaced the
// global AGENTS.md lookup instead of adding to it.
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.acquireRelease(
Effect.promise(async () => {
const realGlobalFile = path.join(Global.Path.config, "AGENTS.md")
await fs.writeFile(realGlobalFile, "real global")
return realGlobalFile
}),
(file) => Effect.promise(() => fs.rm(file)),
).pipe(
Effect.flatMap((realGlobalFile) =>
Effect.gen(function* () {
const overrideDir = path.join(tmp.path, "override")
yield* Effect.promise(() => fs.mkdir(overrideDir, { recursive: true }))

const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: overrideDir,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)

expect((yield* SystemContext.initialize(context)).baseline).toContain(
`Instructions from: ${realGlobalFile}\nreal global`,
)
}),
),
),
),
),
)

it.live("keeps an empty AGENTS.md as available context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand Down
21 changes: 17 additions & 4 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ import { Global } from "@opencode-ai/core/global"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"

/**
* Keeps `OPENCODE_CONFIG_DIR` additive rather than replacing the global
* instructions source (see issue #28658): `Global.configDirs` checks the
* override first (so it still takes priority when it defines its own
* AGENTS.md), then falls back to the real static global default.
*/
export function globalInstructionFiles(
global: Pick<Global.Interface, "home" | "config">,
disableClaudeCodePrompt: boolean,
) {
return [
...Global.configDirs(global.config).map((dir) => path.join(dir, "AGENTS.md")),
...(!disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
}

function extract(messages: SessionV1.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
Expand Down Expand Up @@ -57,10 +73,7 @@ const layer: Layer.Layer<
const global = yield* Global.Service
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const globalFiles = globalInstructionFiles(global, flags.disableClaudeCodePrompt)
const instructionFiles = [
"AGENTS.md",
...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,33 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.globalInstructionFiles", () => {
// Regression coverage for #28658: OPENCODE_CONFIG_DIR replaced the global
// AGENTS.md lookup instead of adding to it.
test("checks the overridden config dir in addition to the real global default", () => {
const files = Instruction.globalInstructionFiles({ home: "/home/user", config: "/override" }, false)
expect(files).toContain(path.join("/override", "AGENTS.md"))
expect(files).toContain(path.join(Global.Path.config, "AGENTS.md"))
})

test("prioritizes the overridden config dir over the real global default", () => {
const files = Instruction.globalInstructionFiles({ home: "/home/user", config: "/override" }, false)
expect(files[0]).toBe(path.join("/override", "AGENTS.md"))
})

test("does not duplicate the path when config matches the real global default", () => {
const files = Instruction.globalInstructionFiles({ home: "/home/user", config: Global.Path.config }, false)
expect(files.filter((file) => file === path.join(Global.Path.config, "AGENTS.md"))).toHaveLength(1)
})

test("includes CLAUDE.md alongside the additive AGENTS.md list when the Claude Code prompt is enabled", () => {
const files = Instruction.globalInstructionFiles({ home: "/home/user", config: "/override" }, false)
expect(files).toContain(path.join("/home/user", ".claude", "CLAUDE.md"))
})

test("omits CLAUDE.md when the Claude Code prompt is disabled", () => {
const files = Instruction.globalInstructionFiles({ home: "/home/user", config: "/override" }, true)
expect(files).not.toContain(path.join("/home/user", ".claude", "CLAUDE.md"))
})
})
Loading