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
56 changes: 32 additions & 24 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core
import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
import { ConfigCommand } from "./command"
import { ConfigLoaderEnv } from "./loader-env"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
import { ConfigLSP } from "./lsp"
Expand Down Expand Up @@ -385,6 +386,7 @@ export const layer = Layer.effect(
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const loaderEnv = yield* ConfigLoaderEnv.Service
const npmSvc = yield* Npm.Service
const http = yield* HttpClient.HttpClient

Expand Down Expand Up @@ -444,7 +446,11 @@ 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 (
Option.isNone(loaderEnv.config) &&
Option.isNone(loaderEnv.configDir) &&
Option.isNone(loaderEnv.inlineConfigContent)
) {
const file = globalConfigFile()
if (!existsSync(file)) {
yield* fs
Expand Down Expand Up @@ -594,12 +600,12 @@ export const layer = Layer.effect(
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
yield* merge(Global.Path.config, global, "global")

if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
if (Option.isSome(loaderEnv.config)) {
yield* merge(loaderEnv.config.value, yield* loadFile(loaderEnv.config.value, authEnv))
log.debug("loaded custom config", { path: loaderEnv.config.value })
}

if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
if (!loaderEnv.disableProjectConfig) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file, authEnv), "local")
}
Expand All @@ -609,16 +615,19 @@ export const layer = Layer.effect(
result.mode = result.mode || {}
result.plugin = result.plugin || []

const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree, {
configDir: loaderEnv.configDir,
disableProjectConfig: loaderEnv.disableProjectConfig,
})

if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
if (Option.isSome(loaderEnv.configDir)) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: loaderEnv.configDir.value })
}

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

for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
if (dir.endsWith(".opencode") || Option.contains(loaderEnv.configDir, dir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
Expand Down Expand Up @@ -663,12 +672,9 @@ export const layer = Layer.effect(
yield* mergePluginOrigins(dir, list)
}

if (process.env.OPENCODE_CONFIG_CONTENT) {
if (Option.isSome(loaderEnv.inlineConfigContent)) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
const next = yield* loadConfig(loaderEnv.inlineConfigContent.value, { dir: ctx.directory, source }, authEnv)
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
Expand All @@ -685,17 +691,18 @@ export const layer = Layer.effect(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
const accountEnv = Option.isSome(tokenOpt)
? { ...authEnv, OPENCODE_CONSOLE_TOKEN: tokenOpt.value }
: authEnv
if (Option.isSome(tokenOpt)) yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)

if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
const next = yield* loadConfig(
JSON.stringify(configOpt.value),
{ dir: path.dirname(source), source },
accountEnv,
)
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
Expand Down Expand Up @@ -741,9 +748,9 @@ export const layer = Layer.effect(
})
}

if (Flag.OPENCODE_PERMISSION) {
if (Option.isSome(loaderEnv.permission)) {
try {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(loaderEnv.permission.value))
} catch (err) {
log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err })
}
Expand Down Expand Up @@ -868,6 +875,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(ConfigLoaderEnv.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/src/config/loader-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export * as ConfigLoaderEnv from "./loader-env"

import { Config as EffectConfig, ConfigProvider, Context, Effect, Layer } from "effect"
import { ConfigService } from "@/effect/config-service"

const fields = {
config: EffectConfig.string("OPENCODE_CONFIG").pipe(EffectConfig.option),
configDir: EffectConfig.string("OPENCODE_CONFIG_DIR").pipe(EffectConfig.option),
inlineConfigContent: EffectConfig.string("OPENCODE_CONFIG_CONTENT").pipe(EffectConfig.option),
disableProjectConfig: EffectConfig.boolean("OPENCODE_DISABLE_PROJECT_CONFIG").pipe(EffectConfig.withDefault(false)),
permission: EffectConfig.string("OPENCODE_PERMISSION").pipe(EffectConfig.option),
}

export class Service extends ConfigService.Service<Service>()("@opencode/ConfigLoaderEnv", fields) {}

export type Info = Context.Service.Shape<typeof Service>

export const layer = (input: Info) => Service.layer(input)
// Build the env-backed provider inside the layer so each layer construction sees
// the current process env instead of a module-load snapshot.
export const defaultLayer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* EffectConfig.all(fields).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv())))
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the declared field shape.
return Service.of(config as Info)
}),
).pipe(Layer.orDie)
13 changes: 9 additions & 4 deletions packages/opencode/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import * as Effect from "effect/Effect"
import { Effect, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"

export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
Expand All @@ -20,11 +20,16 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
})).toReversed()
})

export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
export const directories = Effect.fn("ConfigPaths.directories")(function* (
directory: string,
worktree?: string,
options: { configDir?: Option.Option<string>; disableProjectConfig?: boolean } = {},
) {
const afs = yield* AppFileSystem.Service
const configDir = options.configDir ?? Option.fromUndefinedOr(Flag.OPENCODE_CONFIG_DIR)
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
...(!(options.disableProjectConfig ?? Flag.OPENCODE_DISABLE_PROJECT_CONFIG)
? yield* afs.up({
targets: [".opencode"],
start: directory,
Expand All @@ -36,7 +41,7 @@ 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] : []),
...Option.toArray(configDir),
])
})

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/agent/plugin-agent-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { ConfigLoaderEnv } from "../../src/config/loader-env"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
Expand All @@ -27,6 +28,7 @@ const provider = ProviderTest.fake()
const configLayer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(ConfigLoaderEnv.defaultLayer),
Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop),
Expand Down
Loading
Loading