mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f6135daf | |||
| 3c74c0db30 | |||
| 1fa55dcca7 | |||
| ad0ff9b651 | |||
| 9ef9fac6db | |||
| ef2faaac4c | |||
| e74c99320f |
@@ -1161,503 +1161,501 @@ export namespace Config {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
|
export const layer: Layer.Layer<
|
||||||
Layer.effect(
|
Service,
|
||||||
Service,
|
never,
|
||||||
Effect.gen(function* () {
|
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
|
||||||
const fs = yield* AppFileSystem.Service
|
> = Layer.effect(
|
||||||
const authSvc = yield* Auth.Service
|
Service,
|
||||||
const accountSvc = yield* Account.Service
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const authSvc = yield* Auth.Service
|
||||||
|
const accountSvc = yield* Account.Service
|
||||||
|
const env = yield* Env.Service
|
||||||
|
|
||||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
return yield* fs.readFileString(filepath).pipe(
|
return yield* fs.readFileString(filepath).pipe(
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
(e) => e.reason._tag === "NotFound",
|
(e) => e.reason._tag === "NotFound",
|
||||||
() => Effect.succeed(undefined),
|
() => Effect.succeed(undefined),
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadConfig = Effect.fnUntraced(function* (
|
|
||||||
text: string,
|
|
||||||
options: { path: string } | { dir: string; source: string },
|
|
||||||
) {
|
|
||||||
const original = text
|
|
||||||
const source = "path" in options ? options.path : options.source
|
|
||||||
const isFile = "path" in options
|
|
||||||
const data = yield* Effect.promise(() =>
|
|
||||||
ConfigPaths.parseText(
|
|
||||||
text,
|
|
||||||
"path" in options ? options.path : { source: options.source, dir: options.dir },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const normalized = (() => {
|
|
||||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
|
||||||
const copy = { ...(data as Record<string, unknown>) }
|
|
||||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
|
||||||
if (!hadLegacy) return copy
|
|
||||||
delete copy.theme
|
|
||||||
delete copy.keybinds
|
|
||||||
delete copy.tui
|
|
||||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
|
||||||
return copy
|
|
||||||
})()
|
|
||||||
|
|
||||||
const parsed = Info.safeParse(normalized)
|
|
||||||
if (parsed.success) {
|
|
||||||
if (!parsed.data.$schema && isFile) {
|
|
||||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
|
||||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
|
||||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
|
||||||
}
|
|
||||||
const data = parsed.data
|
|
||||||
if (data.plugin && isFile) {
|
|
||||||
const list = data.plugin
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
|
||||||
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidError({
|
|
||||||
path: source,
|
|
||||||
issues: parsed.error.issues,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
|
||||||
log.info("loading", { path: filepath })
|
|
||||||
const text = yield* readConfigFile(filepath)
|
|
||||||
if (!text) return {} as Info
|
|
||||||
return yield* loadConfig(text, { path: filepath })
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadGlobal = Effect.fnUntraced(function* () {
|
|
||||||
let result: Info = pipe(
|
|
||||||
{},
|
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const legacy = path.join(Global.Path.config, "config")
|
|
||||||
if (existsSync(legacy)) {
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
|
||||||
.then(async (mod) => {
|
|
||||||
const { provider, model, ...rest } = mod.default
|
|
||||||
if (provider && model) result.model = `${provider}/${model}`
|
|
||||||
result["$schema"] = "https://opencode.ai/config.json"
|
|
||||||
result = mergeDeep(result, rest)
|
|
||||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
|
||||||
await fsNode.unlink(legacy)
|
|
||||||
})
|
|
||||||
.catch(() => {}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
|
||||||
loadGlobal().pipe(
|
|
||||||
Effect.tapError((error) =>
|
|
||||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
|
||||||
),
|
|
||||||
Effect.orElseSucceed((): Info => ({})),
|
|
||||||
),
|
),
|
||||||
Duration.infinity,
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadConfig = Effect.fnUntraced(function* (
|
||||||
|
text: string,
|
||||||
|
options: { path: string } | { dir: string; source: string },
|
||||||
|
) {
|
||||||
|
const original = text
|
||||||
|
const source = "path" in options ? options.path : options.source
|
||||||
|
const isFile = "path" in options
|
||||||
|
const data = yield* Effect.promise(() =>
|
||||||
|
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
const normalized = (() => {
|
||||||
return yield* cachedGlobal
|
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||||
|
const copy = { ...(data as Record<string, unknown>) }
|
||||||
|
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||||
|
if (!hadLegacy) return copy
|
||||||
|
delete copy.theme
|
||||||
|
delete copy.keybinds
|
||||||
|
delete copy.tui
|
||||||
|
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||||
|
return copy
|
||||||
|
})()
|
||||||
|
|
||||||
|
const parsed = Info.safeParse(normalized)
|
||||||
|
if (parsed.success) {
|
||||||
|
if (!parsed.data.$schema && isFile) {
|
||||||
|
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||||
|
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||||
|
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||||
|
}
|
||||||
|
const data = parsed.data
|
||||||
|
if (data.plugin && isFile) {
|
||||||
|
const list = data.plugin
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidError({
|
||||||
|
path: source,
|
||||||
|
issues: parsed.error.issues,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
|
log.info("loading", { path: filepath })
|
||||||
|
const text = yield* readConfigFile(filepath)
|
||||||
|
if (!text) return {} as Info
|
||||||
|
return yield* loadConfig(text, { path: filepath })
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadGlobal = Effect.fnUntraced(function* () {
|
||||||
|
let result: Info = pipe(
|
||||||
|
{},
|
||||||
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||||
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||||
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacy = path.join(Global.Path.config, "config")
|
||||||
|
if (existsSync(legacy)) {
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||||
|
.then(async (mod) => {
|
||||||
|
const { provider, model, ...rest } = mod.default
|
||||||
|
if (provider && model) result.model = `${provider}/${model}`
|
||||||
|
result["$schema"] = "https://opencode.ai/config.json"
|
||||||
|
result = mergeDeep(result, rest)
|
||||||
|
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||||
|
await fsNode.unlink(legacy)
|
||||||
|
})
|
||||||
|
.catch(() => {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||||
|
loadGlobal().pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||||
|
),
|
||||||
|
Effect.orElseSucceed((): Info => ({})),
|
||||||
|
),
|
||||||
|
Duration.infinity,
|
||||||
|
)
|
||||||
|
|
||||||
|
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||||
|
return yield* cachedGlobal
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = Effect.fnUntraced(function* (dir: string) {
|
||||||
|
const pkg = path.join(dir, "package.json")
|
||||||
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
|
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
||||||
|
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
const json = yield* fs.readJson(pkg).pipe(
|
||||||
|
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
||||||
|
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
||||||
|
)
|
||||||
|
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
|
||||||
|
const hasIgnore = yield* fs.existsSafe(gitignore)
|
||||||
|
const hasPkg = yield* fs.existsSafe(plugin)
|
||||||
|
|
||||||
|
if (!hasDep) {
|
||||||
|
yield* fs.writeJson(pkg, {
|
||||||
|
...json,
|
||||||
|
dependencies: {
|
||||||
|
...json.dependencies,
|
||||||
|
"@opencode-ai/plugin": target,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIgnore) {
|
||||||
|
yield* fs.writeFileString(
|
||||||
|
gitignore,
|
||||||
|
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDep && hasIgnore && hasPkg) return
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Npm.install(dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
const installDependencies = Effect.fn("Config.installDependencies")(function* (
|
||||||
|
dir: string,
|
||||||
|
input?: InstallInput,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.orElseSucceed(() => false),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
const key =
|
||||||
|
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
|
||||||
|
|
||||||
|
yield* Effect.acquireUseRelease(
|
||||||
|
Effect.promise((signal) =>
|
||||||
|
Flock.acquire(key, {
|
||||||
|
signal,
|
||||||
|
onWait: (tick) =>
|
||||||
|
input?.waitTick?.({
|
||||||
|
dir,
|
||||||
|
attempt: tick.attempt,
|
||||||
|
delay: tick.delay,
|
||||||
|
waited: tick.waited,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => install(dir),
|
||||||
|
(lease) => Effect.promise(() => lease.release()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||||
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
|
let result: Info = {}
|
||||||
|
const consoleManagedProviders = new Set<string>()
|
||||||
|
let activeOrgName: string | undefined
|
||||||
|
|
||||||
|
const scope = Effect.fnUntraced(function* (source: string) {
|
||||||
|
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||||
|
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||||
|
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||||
|
return "global"
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = Effect.fnUntraced(function* (dir: string) {
|
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
|
||||||
const pkg = path.join(dir, "package.json")
|
if (!list?.length) return
|
||||||
const gitignore = path.join(dir, ".gitignore")
|
const hit = kind ?? (yield* scope(source))
|
||||||
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
const plugins = deduplicatePluginOrigins([
|
||||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
...(result.plugin_origins ?? []),
|
||||||
const json = yield* fs.readJson(pkg).pipe(
|
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||||
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
])
|
||||||
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
result.plugin = plugins.map((item) => item.spec)
|
||||||
)
|
result.plugin_origins = plugins
|
||||||
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
|
})
|
||||||
const hasIgnore = yield* fs.existsSafe(gitignore)
|
|
||||||
const hasPkg = yield* fs.existsSafe(plugin)
|
|
||||||
|
|
||||||
if (!hasDep) {
|
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
||||||
yield* fs.writeJson(pkg, {
|
result = mergeConfigConcatArrays(result, next)
|
||||||
...json,
|
return track(source, next.plugin, kind)
|
||||||
dependencies: {
|
}
|
||||||
...json.dependencies,
|
|
||||||
"@opencode-ai/plugin": target,
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
},
|
if (value.type === "wellknown") {
|
||||||
|
const url = key.replace(/\/+$/, "")
|
||||||
|
process.env[value.key] = value.token
|
||||||
|
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||||
|
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||||
|
}
|
||||||
|
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||||
|
const remoteConfig = wellknown.config ?? {}
|
||||||
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
|
const source = `${url}/.well-known/opencode`
|
||||||
|
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||||
|
dir: path.dirname(source),
|
||||||
|
source,
|
||||||
})
|
})
|
||||||
|
yield* merge(source, next, "global")
|
||||||
|
log.debug("loaded remote config from well-known", { url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const global = yield* getGlobal()
|
||||||
|
yield* merge(Global.Path.config, global, "global")
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_CONFIG) {
|
||||||
|
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||||
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
|
for (const file of yield* Effect.promise(() =>
|
||||||
|
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||||
|
)) {
|
||||||
|
yield* merge(file, yield* loadFile(file), "local")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.agent = result.agent || {}
|
||||||
|
result.mode = result.mode || {}
|
||||||
|
result.plugin = result.plugin || []
|
||||||
|
|
||||||
|
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||||
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps: Fiber.Fiber<void, never>[] = []
|
||||||
|
|
||||||
|
for (const dir of unique(directories)) {
|
||||||
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
|
const source = path.join(dir, file)
|
||||||
|
log.debug(`loading config from ${source}`)
|
||||||
|
yield* merge(source, yield* loadFile(source))
|
||||||
|
result.agent ??= {}
|
||||||
|
result.mode ??= {}
|
||||||
|
result.plugin ??= []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasIgnore) {
|
const dep = yield* installDependencies(dir).pipe(
|
||||||
yield* fs.writeFileString(
|
Effect.exit,
|
||||||
gitignore,
|
Effect.tap((exit) =>
|
||||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
Exit.isFailure(exit)
|
||||||
)
|
? Effect.sync(() => {
|
||||||
}
|
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
||||||
|
})
|
||||||
if (hasDep && hasIgnore && hasPkg) return
|
: Effect.void,
|
||||||
|
|
||||||
yield* Effect.promise(() => Npm.install(dir))
|
|
||||||
})
|
|
||||||
|
|
||||||
const installDependencies = Effect.fn("Config.installDependencies")(function* (
|
|
||||||
dir: string,
|
|
||||||
input?: InstallInput,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!(yield* fs.access(dir, { writable: true }).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.orElseSucceed(() => false),
|
|
||||||
))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const key =
|
|
||||||
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
|
|
||||||
|
|
||||||
yield* Effect.acquireUseRelease(
|
|
||||||
Effect.promise((signal) =>
|
|
||||||
Flock.acquire(key, {
|
|
||||||
signal,
|
|
||||||
onWait: (tick) =>
|
|
||||||
input?.waitTick?.({
|
|
||||||
dir,
|
|
||||||
attempt: tick.attempt,
|
|
||||||
delay: tick.delay,
|
|
||||||
waited: tick.waited,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
() => install(dir),
|
Effect.asVoid,
|
||||||
(lease) => Effect.promise(() => lease.release()),
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
})
|
deps.push(dep)
|
||||||
|
|
||||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||||
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||||
|
const list = yield* Effect.promise(() => loadPlugin(dir))
|
||||||
|
yield* track(dir, list)
|
||||||
|
}
|
||||||
|
|
||||||
let result: Info = {}
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||||
const consoleManagedProviders = new Set<string>()
|
const source = "OPENCODE_CONFIG_CONTENT"
|
||||||
let activeOrgName: string | undefined
|
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||||
|
dir: ctx.directory,
|
||||||
const scope = Effect.fnUntraced(function* (source: string) {
|
source,
|
||||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
|
||||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
|
||||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
|
||||||
return "global"
|
|
||||||
})
|
})
|
||||||
|
yield* merge(source, next, "local")
|
||||||
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
|
}
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* (
|
const activeOrg = Option.getOrUndefined(
|
||||||
source: string,
|
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
|
||||||
list: PluginSpec[] | undefined,
|
)
|
||||||
kind?: PluginScope,
|
if (activeOrg) {
|
||||||
) {
|
yield* Effect.gen(function* () {
|
||||||
if (!list?.length) return
|
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||||
const hit = kind ?? (yield* scope(source))
|
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
|
||||||
const plugins = deduplicatePluginOrigins([
|
{ concurrency: 2 },
|
||||||
...(result.plugin_origins ?? []),
|
)
|
||||||
...list.map((spec) => ({ spec, source, scope: hit })),
|
if (Option.isSome(tokenOpt)) {
|
||||||
])
|
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
|
||||||
result.plugin = plugins.map((item) => item.spec)
|
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
|
||||||
result.plugin_origins = plugins
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
activeOrgName = activeOrg.org.name
|
||||||
result = mergeConfigConcatArrays(result, next)
|
|
||||||
return track(source, next.plugin, kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
if (Option.isSome(configOpt)) {
|
||||||
if (value.type === "wellknown") {
|
const source = `${activeOrg.account.url}/api/config`
|
||||||
const url = key.replace(/\/+$/, "")
|
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
|
||||||
process.env[value.key] = value.token
|
|
||||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
|
||||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
|
||||||
}
|
|
||||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
|
||||||
const remoteConfig = wellknown.config ?? {}
|
|
||||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
|
||||||
const source = `${url}/.well-known/opencode`
|
|
||||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
|
||||||
dir: path.dirname(source),
|
dir: path.dirname(source),
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
|
for (const providerID of Object.keys(next.provider ?? {})) {
|
||||||
|
consoleManagedProviders.add(providerID)
|
||||||
|
}
|
||||||
yield* merge(source, next, "global")
|
yield* merge(source, next, "global")
|
||||||
log.debug("loaded remote config from well-known", { url })
|
|
||||||
}
|
}
|
||||||
}
|
}).pipe(
|
||||||
|
Effect.catch((err) => {
|
||||||
const global = yield* getGlobal()
|
log.debug("failed to fetch remote account config", {
|
||||||
yield* merge(Global.Path.config, global, "global")
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
if (Flag.OPENCODE_CONFIG) {
|
return Effect.void
|
||||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
}),
|
||||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
||||||
for (const file of yield* Effect.promise(() =>
|
|
||||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
|
||||||
)) {
|
|
||||||
yield* merge(file, yield* loadFile(file), "local")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.agent = result.agent || {}
|
|
||||||
result.mode = result.mode || {}
|
|
||||||
result.plugin = result.plugin || []
|
|
||||||
|
|
||||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
|
||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
|
||||||
}
|
|
||||||
|
|
||||||
const deps: Fiber.Fiber<void, never>[] = []
|
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
|
||||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
|
||||||
const source = path.join(dir, file)
|
|
||||||
log.debug(`loading config from ${source}`)
|
|
||||||
yield* merge(source, yield* loadFile(source))
|
|
||||||
result.agent ??= {}
|
|
||||||
result.mode ??= {}
|
|
||||||
result.plugin ??= []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dep = yield* installDependencies(dir).pipe(
|
|
||||||
Effect.exit,
|
|
||||||
Effect.tap((exit) =>
|
|
||||||
Exit.isFailure(exit)
|
|
||||||
? Effect.sync(() => {
|
|
||||||
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
|
||||||
})
|
|
||||||
: Effect.void,
|
|
||||||
),
|
|
||||||
Effect.asVoid,
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
deps.push(dep)
|
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
|
||||||
const list = yield* Effect.promise(() => loadPlugin(dir))
|
|
||||||
yield* track(dir, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
|
||||||
const source = "OPENCODE_CONFIG_CONTENT"
|
|
||||||
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
|
||||||
dir: ctx.directory,
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
yield* merge(source, next, "local")
|
|
||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeOrg = Option.getOrUndefined(
|
|
||||||
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
|
|
||||||
)
|
)
|
||||||
if (activeOrg) {
|
}
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
|
||||||
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
if (Option.isSome(tokenOpt)) {
|
|
||||||
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
|
|
||||||
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
activeOrgName = activeOrg.org.name
|
if (existsSync(managedDir)) {
|
||||||
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
if (Option.isSome(configOpt)) {
|
const source = path.join(managedDir, file)
|
||||||
const source = `${activeOrg.account.url}/api/config`
|
yield* merge(source, yield* loadFile(source), "global")
|
||||||
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
|
|
||||||
dir: path.dirname(source),
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
for (const providerID of Object.keys(next.provider ?? {})) {
|
|
||||||
consoleManagedProviders.add(providerID)
|
|
||||||
}
|
|
||||||
yield* merge(source, next, "global")
|
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((err) => {
|
|
||||||
log.debug("failed to fetch remote account config", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existsSync(managedDir)) {
|
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
||||||
const source = path.join(managedDir, file)
|
|
||||||
yield* merge(source, yield* loadFile(source), "global")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||||
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
|
[name]: {
|
||||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
...mode,
|
||||||
result.agent = mergeDeep(result.agent ?? {}, {
|
mode: "primary" as const,
|
||||||
[name]: {
|
|
||||||
...mode,
|
|
||||||
mode: "primary" as const,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_PERMISSION) {
|
|
||||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.tools) {
|
|
||||||
const perms: Record<string, Config.PermissionAction> = {}
|
|
||||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
|
||||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
|
||||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
|
||||||
perms.edit = action
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
perms[tool] = action
|
|
||||||
}
|
|
||||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.username) result.username = os.userInfo().username
|
|
||||||
|
|
||||||
if (result.autoshare === true && !result.share) {
|
|
||||||
result.share = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
|
||||||
result.compaction = { ...result.compaction, auto: false }
|
|
||||||
}
|
|
||||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
|
||||||
result.compaction = { ...result.compaction, prune: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: result,
|
|
||||||
directories,
|
|
||||||
deps,
|
|
||||||
consoleState: {
|
|
||||||
consoleManagedProviders: Array.from(consoleManagedProviders),
|
|
||||||
activeOrgName,
|
|
||||||
switchableOrgCount: 0,
|
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_PERMISSION) {
|
||||||
|
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.tools) {
|
||||||
|
const perms: Record<string, Config.PermissionAction> = {}
|
||||||
|
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||||
|
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||||
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
|
perms.edit = action
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perms[tool] = action
|
||||||
}
|
}
|
||||||
})
|
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
if (!result.username) result.username = os.userInfo().username
|
||||||
Effect.fn("Config.state")(function* (ctx) {
|
|
||||||
return yield* loadInstanceState(ctx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Config.get")(function* () {
|
if (result.autoshare === true && !result.share) {
|
||||||
return yield* InstanceState.use(state, (s) => s.config)
|
result.share = "auto"
|
||||||
})
|
}
|
||||||
|
|
||||||
const directories = Effect.fn("Config.directories")(function* () {
|
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||||
return yield* InstanceState.use(state, (s) => s.directories)
|
result.compaction = { ...result.compaction, auto: false }
|
||||||
})
|
}
|
||||||
|
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||||
|
result.compaction = { ...result.compaction, prune: false }
|
||||||
|
}
|
||||||
|
|
||||||
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
|
return {
|
||||||
return yield* InstanceState.use(state, (s) => s.consoleState)
|
config: result,
|
||||||
})
|
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
|
||||||
yield* InstanceState.useEffect(state, (s) =>
|
|
||||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
const file = path.join(dir, "config.json")
|
|
||||||
const existing = yield* loadFile(file)
|
|
||||||
yield* fs
|
|
||||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
yield* Effect.promise(() => Instance.dispose())
|
|
||||||
})
|
|
||||||
|
|
||||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
|
||||||
yield* invalidateGlobal
|
|
||||||
const task = Instance.disposeAll()
|
|
||||||
.catch(() => undefined)
|
|
||||||
.finally(() =>
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: "global",
|
|
||||||
payload: {
|
|
||||||
type: Event.Disposed.type,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (wait) yield* Effect.promise(() => task)
|
|
||||||
else void task
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
|
||||||
const file = globalConfigFile()
|
|
||||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
|
||||||
const input = writable(config)
|
|
||||||
|
|
||||||
let next: Info
|
|
||||||
if (!file.endsWith(".jsonc")) {
|
|
||||||
const existing = parseConfig(before, file)
|
|
||||||
const merged = mergeDeep(writable(existing), input)
|
|
||||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
|
||||||
next = merged
|
|
||||||
} else {
|
|
||||||
const updated = patchJsonc(before, input)
|
|
||||||
next = parseConfig(updated, file)
|
|
||||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* invalidate()
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
get,
|
|
||||||
getGlobal,
|
|
||||||
getConsoleState,
|
|
||||||
installDependencies,
|
|
||||||
update,
|
|
||||||
updateGlobal,
|
|
||||||
invalidate,
|
|
||||||
directories,
|
directories,
|
||||||
waitForDependencies,
|
deps,
|
||||||
})
|
consoleState: {
|
||||||
}),
|
consoleManagedProviders: Array.from(consoleManagedProviders),
|
||||||
)
|
activeOrgName,
|
||||||
|
switchableOrgCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Config.state")(function* (ctx) {
|
||||||
|
return yield* loadInstanceState(ctx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Config.get")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const directories = Effect.fn("Config.directories")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.directories)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.consoleState)
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||||
|
yield* InstanceState.useEffect(state, (s) =>
|
||||||
|
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
|
const file = path.join(dir, "config.json")
|
||||||
|
const existing = yield* loadFile(file)
|
||||||
|
yield* fs
|
||||||
|
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* Effect.promise(() => Instance.dispose())
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||||
|
yield* invalidateGlobal
|
||||||
|
const task = Instance.disposeAll()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() =>
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
payload: {
|
||||||
|
type: Event.Disposed.type,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (wait) yield* Effect.promise(() => task)
|
||||||
|
else void task
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||||
|
const file = globalConfigFile()
|
||||||
|
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||||
|
const input = writable(config)
|
||||||
|
|
||||||
|
let next: Info
|
||||||
|
if (!file.endsWith(".jsonc")) {
|
||||||
|
const existing = parseConfig(before, file)
|
||||||
|
const merged = mergeDeep(writable(existing), input)
|
||||||
|
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||||
|
next = merged
|
||||||
|
} else {
|
||||||
|
const updated = patchJsonc(before, input)
|
||||||
|
next = parseConfig(updated, file)
|
||||||
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* invalidate()
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
get,
|
||||||
|
getGlobal,
|
||||||
|
getConsoleState,
|
||||||
|
installDependencies,
|
||||||
|
update,
|
||||||
|
updateGlobal,
|
||||||
|
invalidate,
|
||||||
|
directories,
|
||||||
|
waitForDependencies,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+45
-19
@@ -1,28 +1,54 @@
|
|||||||
import { Instance } from "../project/instance"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
export namespace Env {
|
export namespace Env {
|
||||||
const state = Instance.state(() => {
|
type State = Record<string, string | undefined>
|
||||||
// Create a shallow copy to isolate environment per instance
|
|
||||||
// Prevents parallel tests from interfering with each other's env vars
|
|
||||||
return { ...process.env } as Record<string, string | undefined>
|
|
||||||
})
|
|
||||||
|
|
||||||
export function get(key: string) {
|
export interface Interface {
|
||||||
const env = state()
|
readonly get: (key: string) => Effect.Effect<string | undefined>
|
||||||
return env[key]
|
readonly all: () => Effect.Effect<State>
|
||||||
|
readonly set: (key: string, value: string) => Effect.Effect<void>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function all() {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
|
||||||
return state()
|
|
||||||
}
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Env.state")(() =>
|
||||||
|
Effect.succeed(
|
||||||
|
// Create a shallow copy to isolate environment per instance
|
||||||
|
// Prevents parallel tests from interfering with each other's env vars
|
||||||
|
{ ...process.env } as State,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
|
||||||
|
|
||||||
|
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
|
||||||
|
|
||||||
|
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
env[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Env.remove")(function* (key: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
delete env[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
const rt = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export function set(key: string, value: string) {
|
export function set(key: string, value: string) {
|
||||||
const env = state()
|
return rt.runSync((svc) => svc.set(key, value))
|
||||||
env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remove(key: string) {
|
|
||||||
const env = state()
|
|
||||||
delete env[key]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ export namespace ToolRegistry {
|
|||||||
| Ripgrep.Service
|
| Ripgrep.Service
|
||||||
| Format.Service
|
| Format.Service
|
||||||
| Truncate.Service
|
| Truncate.Service
|
||||||
|
| Env.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -103,6 +104,7 @@ export namespace ToolRegistry {
|
|||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const truncate = yield* Truncate.Service
|
const truncate = yield* Truncate.Service
|
||||||
|
const env = yield* Env.Service
|
||||||
|
|
||||||
const invalid = yield* InvalidTool
|
const invalid = yield* InvalidTool
|
||||||
const task = yield* TaskTool
|
const task = yield* TaskTool
|
||||||
@@ -272,13 +274,14 @@ export namespace ToolRegistry {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||||
|
const e2e = !!(yield* env.get("OPENCODE_E2E_LLM_URL"))
|
||||||
const filtered = (yield* all()).filter((tool) => {
|
const filtered = (yield* all()).filter((tool) => {
|
||||||
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
|
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
|
||||||
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||||
}
|
}
|
||||||
|
|
||||||
const usePatch =
|
const usePatch =
|
||||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
e2e ||
|
||||||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
|
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
|
||||||
if (tool.id === ApplyPatchTool.id) return usePatch
|
if (tool.id === ApplyPatchTool.id) return usePatch
|
||||||
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
||||||
@@ -342,6 +345,7 @@ export namespace ToolRegistry {
|
|||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:
|
|||||||
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
|
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
import { Env } from "../../src/env"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||||
@@ -37,6 +38,7 @@ const layer = Config.layer.pipe(
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(emptyAuth),
|
Layer.provide(emptyAuth),
|
||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,6 +336,7 @@ test("resolves env templates in account config with account token", async () =>
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(emptyAuth),
|
Layer.provide(emptyAuth),
|
||||||
Layer.provide(fakeAccount),
|
Layer.provide(fakeAccount),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1826,6 +1829,7 @@ test("project config overrides remote well-known config", async () => {
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(fakeAuth),
|
Layer.provide(fakeAuth),
|
||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1881,6 +1885,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(fakeAuth),
|
Layer.provide(fakeAuth),
|
||||||
Layer.provide(emptyAccount),
|
Layer.provide(emptyAccount),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provideMerge(infra),
|
Layer.provideMerge(infra),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
|||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Command } from "../../src/command"
|
import { Command } from "../../src/command"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
import { Env } from "../../src/env"
|
||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { MCP } from "../../src/mcp"
|
import { MCP } from "../../src/mcp"
|
||||||
@@ -183,6 +184,7 @@ function makeHttp() {
|
|||||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||||
const registry = ToolRegistry.layer.pipe(
|
const registry = ToolRegistry.layer.pipe(
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
|
|||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Command } from "../../src/command"
|
import { Command } from "../../src/command"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
import { Env } from "../../src/env"
|
||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { MCP } from "../../src/mcp"
|
import { MCP } from "../../src/mcp"
|
||||||
@@ -137,6 +138,7 @@ function makeHttp() {
|
|||||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||||
const registry = ToolRegistry.layer.pipe(
|
const registry = ToolRegistry.layer.pipe(
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
|
|||||||
Reference in New Issue
Block a user