feat(core): add location-scoped config loading

This commit is contained in:
Dax Raad 2026-05-27 16:27:46 -04:00
commit 9e556b0f6c
26 changed files with 727 additions and 195 deletions

View file

@ -6,9 +6,14 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),

View file

@ -0,0 +1,208 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { ConfigV2 } from "@opencode-ai/core/config/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
function testLayer(
directory: string,
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
) {
return Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
),
),
),
),
)
}
const provider = {
endpoint: { type: "unknown" },
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
models: {},
}
describe("Config", () => {
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(
path.join(tmp.path, "config.json"),
JSON.stringify({ $schema: "base", providers: { base: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Later global files override scalar fields while retaining providers.
"$schema": "last",
"providers": { "last": ${JSON.stringify(provider)} },
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(3)
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
expect(documents[0]).toBeInstanceOf(ConfigV2.Loaded)
expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource)
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
path.join(tmp.path, "config.json"),
)
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
)
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("ignores invalid files while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
const parent = path.join(root, "packages")
const directory = path.join(parent, "app")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await Promise.all([
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
fs.writeFile(
path.join(directory, ".opencode", "opencode.jsonc"),
JSON.stringify({ $schema: "directory-dot" }),
),
])
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const directories = yield* config.directories()
const documents = yield* config.get()
expect(directories).toEqual([
AbsolutePath.make(global),
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
"root",
"parent",
"directory",
"root-dot",
"directory-dot",
])
}).pipe(
Effect.provide(
testLayer(directory, global, root, {
type: "git",
store: AbsolutePath.make(path.join(root, ".git")),
}),
),
)
})
}),
),
)
})

View file

@ -0,0 +1,138 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { ConfigV2 } from "@opencode-ai/core/config/schema"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
function options(headers: Record<string, string>, variant?: string) {
return {
headers,
body: {},
aisdk: {
provider: {},
request: {},
},
variant,
}
}
const decode = Schema.decodeUnknownSync(ConfigV2.Info)
describe("ConfigProvider.Plugin", () => {
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
Effect.succeed([
new ConfigV2.Loaded({
source: new ConfigV2.MemorySource({ type: "memory" }),
info: decode({
providers: {
custom: {
name: "Configured",
endpoint: { type: "unknown" },
options: options({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
enabled: false,
limit: { context: 100, output: 50 },
options: options({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
body: {},
aisdk: { provider: {}, request: {} },
},
],
},
},
},
},
}),
}),
new ConfigV2.Loaded({
source: new ConfigV2.MemorySource({ type: "memory" }),
info: decode({
providers: {
custom: {
endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
options: options({ last: "last", shared: "last" }),
models: {
chat: {
apiID: "api-chat",
name: "Last",
options: options({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
body: {},
aisdk: { provider: {}, request: {} },
},
{
id: "slow",
headers: { slow: "slow" },
body: {},
aisdk: { provider: {}, request: {} },
},
],
},
},
},
},
}),
}),
new ConfigV2.Loaded({
source: new ConfigV2.MemorySource({ type: "memory" }),
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
]),
})
yield* plugin.add({
...ConfigProvider.Plugin,
effect: ConfigProvider.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider.name).toBe("Renamed")
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 50 })
expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.options.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
)
})

View file

@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
@ -46,7 +48,7 @@ describe("EventV2", () => {
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
}),
)

View file

@ -0,0 +1,12 @@
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
} satisfies Location.Interface
}

View file

@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
}),
)
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
Effect.gen(function* () {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe("workspace")
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({
type: "git",
store: AbsolutePath.make("/repo/.git"),
})
}),
)
})

View file

@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
@ -16,7 +18,9 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)

View file

@ -9,6 +9,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
@ -17,7 +19,9 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)

View file

@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
@ -31,7 +33,9 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)

View file

@ -8,10 +8,15 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
export const npmLayer = Layer.succeed(
Npm.Service,

View file

@ -6,10 +6,15 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>

View file

@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),