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
36 changes: 24 additions & 12 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,17 @@ export const layer = Layer.effect(
]
})

const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const extraConfigDirs = Array.from(
new Map(
global.extraConfigDirs
.filter((directory) => path.resolve(directory) !== path.resolve(global.config))
.map((directory) => [path.resolve(directory), directory]),
).values(),
)
const extraConfigDirSet = new Set(extraConfigDirs.map((directory) => path.resolve(directory)))
const locationIsGlobal = [global.config, ...extraConfigDirs].some(
(directory) => path.resolve(location.directory) === path.resolve(directory),
)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const discovered = locationIsGlobal
Expand All @@ -182,24 +191,27 @@ export const layer = Layer.effect(
stop: location.project.directory,
})
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const projectDirectories = discovered
.filter((item) => path.basename(item) === ".opencode")
.filter((item) => !extraConfigDirSet.has(path.resolve(item)))
.toReversed()
.map((directory) => AbsolutePath.make(directory))
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
const globalSupplementary = yield* loadDirectory(AbsolutePath.make(global.config)).pipe(Effect.orDie)
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(Effect.orDie)
const extraSupplementary = yield* Effect.forEach(
extraConfigDirs.map((directory) => AbsolutePath.make(directory)),
loadDirectory,
).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// global config, project files, `.opencode` files, then env-provided config directories.
const configs = [...globalSupplementary, ...direct, ...projectSupplementary.flat(), ...extraSupplementary.flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path"
import { Config } from "effect"

export function truthy(key: string) {
Expand All @@ -12,6 +13,20 @@ function enabledByExperimental(key: string) {
return process.env[key] === undefined ? truthy("OPENCODE_EXPERIMENTAL") : truthy(key)
}

function splitPathList(value: string | undefined) {
return (
value
?.split(path.delimiter)
.map((item) => item.trim())
.filter((item) => item.length > 0) ?? []
)
}

export function configDirectories() {
const directory = process.env["OPENCODE_CONFIG_DIR"]?.trim()
return [...splitPathList(process.env["OPENCODE_CONFIG_DIRS"]), ...(directory ? [directory] : [])]
}

export const Flag = {
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
Expand Down Expand Up @@ -61,7 +76,11 @@ export const Flag = {
return process.env["OPENCODE_TUI_CONFIG"]
},
get OPENCODE_CONFIG_DIR() {
return process.env["OPENCODE_CONFIG_DIR"]
const directory = process.env["OPENCODE_CONFIG_DIR"]?.trim()
return directory || undefined
},
get OPENCODE_CONFIG_DIRS() {
return splitPathList(process.env["OPENCODE_CONFIG_DIRS"])
},
get OPENCODE_PURE() {
return truthy("OPENCODE_PURE")
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
import { configDirectories } from "./flag/flag"
import { LayerNode } from "./effect/layer-node"

const app = "opencode"
Expand Down Expand Up @@ -49,6 +49,8 @@ export interface Interface {
readonly data: string
readonly cache: string
readonly config: string
/** Additional config directory layers loaded after the XDG config directory. */
readonly extraConfigDirs: readonly string[]
readonly state: string
readonly tmp: string
readonly bin: string
Expand All @@ -61,7 +63,8 @@ export function make(input: Partial<Interface> = {}): Interface {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
config: Path.config,
extraConfigDirs: configDirectories(),
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/instruction-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,19 @@ export const layer = Layer.effectDiscard(
})
).map(FSUtil.resolve),
)
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered])
const globalInstructionPaths = yield* Effect.forEach(
[global.config, ...global.extraConfigDirs].toReversed(),
(directory) =>
Effect.gen(function* () {
const filepath = FSUtil.resolve(join(directory, "AGENTS.md"))
return (yield* fs.existsSafe(filepath)) ? filepath : undefined
}),
{ concurrency: "unbounded" },
)
const paths = Array.dedupe([
...globalInstructionPaths.filter((path): path is string => path !== undefined).slice(0, 1),
...discovered,
])
const files = yield* Effect.forEach(
paths,
(path) =>
Expand Down
76 changes: 75 additions & 1 deletion packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ function testLayer(
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
extraConfigDirs: readonly string[] = [],
) {
return Config.locationLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provide(Global.layerWith({ config: globalDirectory, extraConfigDirs })),
Layer.provide(
Layer.succeed(
Location.Service,
Expand Down Expand Up @@ -770,4 +771,77 @@ describe("Config", () => {
}),
),
)

it.live("loads custom config directories after project config with singular last", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const firstExtra = path.join(tmp.path, "extra-a")
const secondExtra = path.join(tmp.path, "extra-b")
const singularExtra = path.join(tmp.path, "extra-singular")
const root = path.join(tmp.path, "repo")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(firstExtra, { recursive: true })
await fs.mkdir(secondExtra, { recursive: true })
await fs.mkdir(singularExtra, { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await Promise.all([
fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({ $schema: "global", model: "global/model" }),
),
fs.writeFile(
path.join(firstExtra, "opencode.json"),
JSON.stringify({ $schema: "extra-a", model: "extra-a/model" }),
),
fs.writeFile(
path.join(secondExtra, "opencode.json"),
JSON.stringify({ $schema: "extra-b", model: "extra-b/model" }),
),
fs.writeFile(
path.join(singularExtra, "opencode.json"),
JSON.stringify({ $schema: "extra-singular", model: "extra-singular/model" }),
),
fs.writeFile(
path.join(root, "opencode.json"),
JSON.stringify({ $schema: "project", model: "project/model" }),
),
fs.writeFile(
path.join(root, ".opencode", "opencode.json"),
JSON.stringify({ $schema: "project-dot", model: "project-dot/model" }),
),
])
})

return yield* Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
const documents = entries.filter((entry) => entry.type === "document")

expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
AbsolutePath.make(global),
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(firstExtra),
AbsolutePath.make(secondExtra),
AbsolutePath.make(singularExtra),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"project",
"project-dot",
"extra-a",
"extra-b",
"extra-singular",
])
expect(Config.latest(entries, "model")).toBe("extra-singular/model")
}).pipe(Effect.provide(testLayer(root, global, root, undefined, [firstExtra, secondExtra, singularExtra])))
})
}),
),
)
})
22 changes: 22 additions & 0 deletions packages/core/test/global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,26 @@ describe("global paths", () => {
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})

test("config directory env vars add extra config directories", () => {
const previousDir = process.env.OPENCODE_CONFIG_DIR
const previousDirs = process.env.OPENCODE_CONFIG_DIRS
process.env.OPENCODE_CONFIG_DIR = " /tmp/opencode-extra-config "
process.env.OPENCODE_CONFIG_DIRS = [" /tmp/opencode-extra-a ", "", "/tmp/opencode-extra-b"].join(path.delimiter)
try {
const global = Global.make()

expect(global.config).toBe(Global.Path.config)
expect(global.extraConfigDirs).toEqual([
"/tmp/opencode-extra-a",
"/tmp/opencode-extra-b",
"/tmp/opencode-extra-config",
])
} finally {
if (previousDir === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = previousDir
if (previousDirs === undefined) delete process.env.OPENCODE_CONFIG_DIRS
else process.env.OPENCODE_CONFIG_DIRS = previousDirs
}
})
})
40 changes: 40 additions & 0 deletions packages/core/test/instruction-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,46 @@ describe("InstructionContext", () => {
),
)

it.live("uses highest-priority extra config directory AGENTS.md", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const base = path.join(tmp.path, "base")
const team = path.join(tmp.path, "team")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(base, { recursive: true })
await fs.mkdir(team, { recursive: true })
await fs.writeFile(path.join(global, "AGENTS.md"), "global")
await fs.writeFile(path.join(base, "AGENTS.md"), "base")
await fs.writeFile(path.join(team, "AGENTS.md"), "team")
})

const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(Global.layerWith({ config: global, extraConfigDirs: [base, team] })),
Effect.provide(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
),
)

expect((yield* SystemContext.initialize(context)).baseline).toBe(
`Instructions from: ${path.join(team, "AGENTS.md")}\nteam`,
)
}),
),
),
)

it.live("keeps an empty AGENTS.md as available context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand Down
16 changes: 12 additions & 4 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,12 @@ export const layer = Layer.effect(
let result: Info = {}
// Seed the default global config with the schema for editor completion, but avoid writing when the user
// explicitly routes config through env-provided paths or content.
if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) {
if (
!Flag.OPENCODE_CONFIG &&
!Flag.OPENCODE_CONFIG_DIR &&
!Flag.OPENCODE_CONFIG_DIRS.length &&
!Flag.OPENCODE_CONFIG_CONTENT
) {
const file = globalConfigFile()
if (!existsSync(file)) {
yield* fs
Expand Down Expand Up @@ -414,14 +419,17 @@ export const layer = Layer.effect(

const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)

if (Flag.OPENCODE_CONFIG_DIR) {
yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
const extraConfigDirs = ConfigPaths.customDirectories()
const extraConfigDirSet = new Set(extraConfigDirs)

if (extraConfigDirs.length) {
yield* Effect.logDebug("loading config from extra config dirs", { paths: extraConfigDirs })
}

const deps: Fiber.Fiber<void>[] = []

for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
if (dir.endsWith(".opencode") || extraConfigDirSet.has(dir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
yield* Effect.logDebug(`loading config from ${source}`)
Expand Down
18 changes: 14 additions & 4 deletions packages/opencode/src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as ConfigPaths from "./paths"

import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Flag, configDirectories } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import * as Effect from "effect/Effect"
Expand All @@ -22,7 +22,9 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (

export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* FSUtil.Service
return unique([
const custom = customDirectories()
const customSet = new Set(custom.map((dir) => path.resolve(dir)))
const base = [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? yield* afs.up({
Expand All @@ -36,10 +38,18 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
].filter((dir) => !customSet.has(path.resolve(dir)))
return unique([...base, ...custom])
})

export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}

export function customDirectories() {
return unique(configDirectories())
}

export function isConfigDirectory(dir: string) {
return dir.endsWith(".opencode") || customDirectories().some((custom) => path.resolve(custom) === path.resolve(dir))
}
Loading
Loading