|
@@ -4,7 +4,6 @@ import { pathToFileURL, fileURLToPath } from "url"
|
|
|
import { createRequire } from "module"
|
|
import { createRequire } from "module"
|
|
|
import os from "os"
|
|
import os from "os"
|
|
|
import z from "zod"
|
|
import z from "zod"
|
|
|
-import { Filesystem } from "../util/filesystem"
|
|
|
|
|
import { ModelsDev } from "../provider/models"
|
|
import { ModelsDev } from "../provider/models"
|
|
|
import { mergeDeep, pipe, unique } from "remeda"
|
|
import { mergeDeep, pipe, unique } from "remeda"
|
|
|
import { Global } from "../global"
|
|
import { Global } from "../global"
|
|
@@ -34,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
|
|
|
import { proxied } from "@/util/proxied"
|
|
import { proxied } from "@/util/proxied"
|
|
|
import { iife } from "@/util/iife"
|
|
import { iife } from "@/util/iife"
|
|
|
import { Control } from "@/control"
|
|
import { Control } from "@/control"
|
|
|
|
|
+import { ConfigPaths } from "./paths"
|
|
|
|
|
+import { Filesystem } from "@/util/filesystem"
|
|
|
|
|
|
|
|
export namespace Config {
|
|
export namespace Config {
|
|
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
|
@@ -42,7 +43,7 @@ export namespace Config {
|
|
|
|
|
|
|
|
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
|
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
|
|
// These settings override all user and project settings
|
|
// These settings override all user and project settings
|
|
|
- function getManagedConfigDir(): string {
|
|
|
|
|
|
|
+ function systemManagedConfigDir(): string {
|
|
|
switch (process.platform) {
|
|
switch (process.platform) {
|
|
|
case "darwin":
|
|
case "darwin":
|
|
|
return "/Library/Application Support/opencode"
|
|
return "/Library/Application Support/opencode"
|
|
@@ -53,10 +54,14 @@ export namespace Config {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
|
|
|
|
|
|
|
+ export function managedConfigDir() {
|
|
|
|
|
+ return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const managedDir = managedConfigDir()
|
|
|
|
|
|
|
|
// Custom merge function that concatenates array fields instead of replacing them
|
|
// Custom merge function that concatenates array fields instead of replacing them
|
|
|
- function merge(target: Info, source: Info): Info {
|
|
|
|
|
|
|
+ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
|
|
const merged = mergeDeep(target, source)
|
|
const merged = mergeDeep(target, source)
|
|
|
if (target.plugin && source.plugin) {
|
|
if (target.plugin && source.plugin) {
|
|
|
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
|
|
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
|
|
@@ -91,7 +96,7 @@ export namespace Config {
|
|
|
const remoteConfig = wellknown.config ?? {}
|
|
const remoteConfig = wellknown.config ?? {}
|
|
|
// Add $schema to prevent load() from trying to write back to a non-existent file
|
|
// Add $schema to prevent load() from trying to write back to a non-existent file
|
|
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
|
|
- result = merge(
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(
|
|
|
result,
|
|
result,
|
|
|
await load(JSON.stringify(remoteConfig), {
|
|
await load(JSON.stringify(remoteConfig), {
|
|
|
dir: path.dirname(`${key}/.well-known/opencode`),
|
|
dir: path.dirname(`${key}/.well-known/opencode`),
|
|
@@ -107,21 +112,18 @@ export namespace Config {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Global user config overrides remote config.
|
|
// Global user config overrides remote config.
|
|
|
- result = merge(result, await global())
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(result, await global())
|
|
|
|
|
|
|
|
// Custom config path overrides global config.
|
|
// Custom config path overrides global config.
|
|
|
if (Flag.OPENCODE_CONFIG) {
|
|
if (Flag.OPENCODE_CONFIG) {
|
|
|
- result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
|
|
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Project config overrides global and remote config.
|
|
// Project config overrides global and remote config.
|
|
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
|
- for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
|
|
|
- const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
|
|
|
|
- for (const resolved of found.toReversed()) {
|
|
|
|
|
- result = merge(result, await loadFile(resolved))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
|
|
|
|
|
+ result = mergeConfigConcatArrays(result, await loadFile(file))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -129,31 +131,10 @@ export namespace Config {
|
|
|
result.mode = result.mode || {}
|
|
result.mode = result.mode || {}
|
|
|
result.plugin = result.plugin || []
|
|
result.plugin = result.plugin || []
|
|
|
|
|
|
|
|
- const directories = [
|
|
|
|
|
- Global.Path.config,
|
|
|
|
|
- // Only scan project .opencode/ directories when project discovery is enabled
|
|
|
|
|
- ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
|
|
|
- ? await Array.fromAsync(
|
|
|
|
|
- Filesystem.up({
|
|
|
|
|
- targets: [".opencode"],
|
|
|
|
|
- start: Instance.directory,
|
|
|
|
|
- stop: Instance.worktree,
|
|
|
|
|
- }),
|
|
|
|
|
- )
|
|
|
|
|
- : []),
|
|
|
|
|
- // Always scan ~/.opencode/ (user home directory)
|
|
|
|
|
- ...(await Array.fromAsync(
|
|
|
|
|
- Filesystem.up({
|
|
|
|
|
- targets: [".opencode"],
|
|
|
|
|
- start: Global.Path.home,
|
|
|
|
|
- stop: Global.Path.home,
|
|
|
|
|
- }),
|
|
|
|
|
- )),
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
|
|
|
|
|
|
|
// .opencode directory config overrides (project and global) config sources.
|
|
// .opencode directory config overrides (project and global) config sources.
|
|
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
|
|
- directories.push(Flag.OPENCODE_CONFIG_DIR)
|
|
|
|
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -163,7 +144,7 @@ export namespace Config {
|
|
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
|
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
|
log.debug(`loading config from ${path.join(dir, file)}`)
|
|
log.debug(`loading config from ${path.join(dir, file)}`)
|
|
|
- result = merge(result, await loadFile(path.join(dir, file)))
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
|
|
|
// to satisfy the type checker
|
|
// to satisfy the type checker
|
|
|
result.agent ??= {}
|
|
result.agent ??= {}
|
|
|
result.mode ??= {}
|
|
result.mode ??= {}
|
|
@@ -186,7 +167,7 @@ export namespace Config {
|
|
|
|
|
|
|
|
// Inline config content overrides all non-managed config sources.
|
|
// Inline config content overrides all non-managed config sources.
|
|
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
|
|
- result = merge(
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(
|
|
|
result,
|
|
result,
|
|
|
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
|
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
|
|
dir: Instance.directory,
|
|
dir: Instance.directory,
|
|
@@ -200,9 +181,9 @@ export namespace Config {
|
|
|
// Kept separate from directories array to avoid write operations when installing plugins
|
|
// Kept separate from directories array to avoid write operations when installing plugins
|
|
|
// which would fail on system directories requiring elevated permissions
|
|
// which would fail on system directories requiring elevated permissions
|
|
|
// This way it only loads config file and not skills/plugins/commands
|
|
// This way it only loads config file and not skills/plugins/commands
|
|
|
- if (existsSync(managedConfigDir)) {
|
|
|
|
|
|
|
+ if (existsSync(managedDir)) {
|
|
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
|
- result = merge(result, await loadFile(path.join(managedConfigDir, file)))
|
|
|
|
|
|
|
+ result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -241,8 +222,6 @@ export namespace Config {
|
|
|
result.share = "auto"
|
|
result.share = "auto"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
|
|
|
|
|
-
|
|
|
|
|
// Apply flag overrides for compaction settings
|
|
// Apply flag overrides for compaction settings
|
|
|
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
|
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
|
|
result.compaction = { ...result.compaction, auto: false }
|
|
result.compaction = { ...result.compaction, auto: false }
|
|
@@ -306,7 +285,7 @@ export namespace Config {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- async function needsInstall(dir: string) {
|
|
|
|
|
|
|
+ export async function needsInstall(dir: string) {
|
|
|
// Some config dirs may be read-only.
|
|
// Some config dirs may be read-only.
|
|
|
// Installing deps there will fail; skip installation in that case.
|
|
// Installing deps there will fail; skip installation in that case.
|
|
|
const writable = await isWritable(dir)
|
|
const writable = await isWritable(dir)
|
|
@@ -930,20 +909,6 @@ export namespace Config {
|
|
|
ref: "KeybindsConfig",
|
|
ref: "KeybindsConfig",
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- export const TUI = z.object({
|
|
|
|
|
- scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
|
|
|
|
- scroll_acceleration: z
|
|
|
|
|
- .object({
|
|
|
|
|
- enabled: z.boolean().describe("Enable scroll acceleration"),
|
|
|
|
|
- })
|
|
|
|
|
- .optional()
|
|
|
|
|
- .describe("Scroll acceleration settings"),
|
|
|
|
|
- diff_style: z
|
|
|
|
|
- .enum(["auto", "stacked"])
|
|
|
|
|
- .optional()
|
|
|
|
|
- .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
export const Server = z
|
|
export const Server = z
|
|
|
.object({
|
|
.object({
|
|
|
port: z.number().int().positive().optional().describe("Port to listen on"),
|
|
port: z.number().int().positive().optional().describe("Port to listen on"),
|
|
@@ -1018,10 +983,7 @@ export namespace Config {
|
|
|
export const Info = z
|
|
export const Info = z
|
|
|
.object({
|
|
.object({
|
|
|
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
|
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
|
|
- theme: z.string().optional().describe("Theme name to use for the interface"),
|
|
|
|
|
- keybinds: Keybinds.optional().describe("Custom keybind configurations"),
|
|
|
|
|
logLevel: Log.Level.optional().describe("Log level"),
|
|
logLevel: Log.Level.optional().describe("Log level"),
|
|
|
- tui: TUI.optional().describe("TUI specific settings"),
|
|
|
|
|
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
|
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
|
|
command: z
|
|
command: z
|
|
|
.record(z.string(), Command)
|
|
.record(z.string(), Command)
|
|
@@ -1241,86 +1203,37 @@ export namespace Config {
|
|
|
return result
|
|
return result
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ export const { readFile } = ConfigPaths
|
|
|
|
|
+
|
|
|
async function loadFile(filepath: string): Promise<Info> {
|
|
async function loadFile(filepath: string): Promise<Info> {
|
|
|
log.info("loading", { path: filepath })
|
|
log.info("loading", { path: filepath })
|
|
|
- let text = await Filesystem.readText(filepath).catch((err: any) => {
|
|
|
|
|
- if (err.code === "ENOENT") return
|
|
|
|
|
- throw new JsonError({ path: filepath }, { cause: err })
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const text = await readFile(filepath)
|
|
|
if (!text) return {}
|
|
if (!text) return {}
|
|
|
return load(text, { path: filepath })
|
|
return load(text, { path: filepath })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
|
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
|
|
const original = text
|
|
const original = text
|
|
|
- const configDir = "path" in options ? path.dirname(options.path) : options.dir
|
|
|
|
|
const source = "path" in options ? options.path : options.source
|
|
const source = "path" in options ? options.path : options.source
|
|
|
const isFile = "path" in options
|
|
const isFile = "path" in options
|
|
|
|
|
+ const data = await ConfigPaths.parseText(
|
|
|
|
|
+ text,
|
|
|
|
|
+ "path" in options ? options.path : { source: options.source, dir: options.dir },
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
|
|
|
|
- return process.env[varName] || ""
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const fileMatches = text.match(/\{file:[^}]+\}/g)
|
|
|
|
|
- if (fileMatches) {
|
|
|
|
|
- const lines = text.split("\n")
|
|
|
|
|
-
|
|
|
|
|
- for (const match of fileMatches) {
|
|
|
|
|
- const lineIndex = lines.findIndex((line) => line.includes(match))
|
|
|
|
|
- if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
- let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
|
|
|
|
|
- if (filePath.startsWith("~/")) {
|
|
|
|
|
- filePath = path.join(os.homedir(), filePath.slice(2))
|
|
|
|
|
- }
|
|
|
|
|
- const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
|
|
|
|
- const fileContent = (
|
|
|
|
|
- await Bun.file(resolvedPath)
|
|
|
|
|
- .text()
|
|
|
|
|
- .catch((error) => {
|
|
|
|
|
- const errMsg = `bad file reference: "${match}"`
|
|
|
|
|
- if (error.code === "ENOENT") {
|
|
|
|
|
- throw new InvalidError(
|
|
|
|
|
- {
|
|
|
|
|
- path: source,
|
|
|
|
|
- message: errMsg + ` ${resolvedPath} does not exist`,
|
|
|
|
|
- },
|
|
|
|
|
- { cause: error },
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- throw new InvalidError({ path: source, message: errMsg }, { cause: error })
|
|
|
|
|
- })
|
|
|
|
|
- ).trim()
|
|
|
|
|
- text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const errors: JsoncParseError[] = []
|
|
|
|
|
- const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
|
|
|
|
- if (errors.length) {
|
|
|
|
|
- const lines = text.split("\n")
|
|
|
|
|
- const errorDetails = errors
|
|
|
|
|
- .map((e) => {
|
|
|
|
|
- const beforeOffset = text.substring(0, e.offset).split("\n")
|
|
|
|
|
- const line = beforeOffset.length
|
|
|
|
|
- const column = beforeOffset[beforeOffset.length - 1].length + 1
|
|
|
|
|
- const problemLine = lines[line - 1]
|
|
|
|
|
-
|
|
|
|
|
- const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
|
|
|
|
- if (!problemLine) return error
|
|
|
|
|
-
|
|
|
|
|
- return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
|
|
|
|
- })
|
|
|
|
|
- .join("\n")
|
|
|
|
|
-
|
|
|
|
|
- throw new JsonError({
|
|
|
|
|
- path: source,
|
|
|
|
|
- message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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(data)
|
|
|
|
|
|
|
+ const parsed = Info.safeParse(normalized)
|
|
|
if (parsed.success) {
|
|
if (parsed.success) {
|
|
|
if (!parsed.data.$schema && isFile) {
|
|
if (!parsed.data.$schema && isFile) {
|
|
|
parsed.data.$schema = "https://opencode.ai/config.json"
|
|
parsed.data.$schema = "https://opencode.ai/config.json"
|
|
@@ -1353,13 +1266,7 @@ export namespace Config {
|
|
|
issues: parsed.error.issues,
|
|
issues: parsed.error.issues,
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
- export const JsonError = NamedError.create(
|
|
|
|
|
- "ConfigJsonError",
|
|
|
|
|
- z.object({
|
|
|
|
|
- path: z.string(),
|
|
|
|
|
- message: z.string().optional(),
|
|
|
|
|
- }),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ export const { JsonError, InvalidError } = ConfigPaths
|
|
|
|
|
|
|
|
export const ConfigDirectoryTypoError = NamedError.create(
|
|
export const ConfigDirectoryTypoError = NamedError.create(
|
|
|
"ConfigDirectoryTypoError",
|
|
"ConfigDirectoryTypoError",
|
|
@@ -1370,15 +1277,6 @@ export namespace Config {
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- export const InvalidError = NamedError.create(
|
|
|
|
|
- "ConfigInvalidError",
|
|
|
|
|
- z.object({
|
|
|
|
|
- path: z.string(),
|
|
|
|
|
- issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
|
|
|
|
- message: z.string().optional(),
|
|
|
|
|
- }),
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
export async function get() {
|
|
export async function get() {
|
|
|
return state().then((x) => x.config)
|
|
return state().then((x) => x.config)
|
|
|
}
|
|
}
|