跳转到内容

Config 源码解读

模块概述

Config 模块管理 OpenCode 的所有配置——从模型选择到权限规则、从 MCP 服务器到快捷键绑定。它采用 Zod Schema 驱动的类型系统,配合六层配置源的优先级合并,让项目级共享配置与用户个人偏好有序共存。

核心设计选择:

  • Zod Schema 全覆盖:所有类型定义使用 Zod,运行时可验证、编译时类型安全
  • 六层配置优先级:内置默认值 → 全局配置 → 环境变量文件 → 项目配置 → .opencode/ → 环境变量内容
  • JSONC + 变量替换:支持注释的 JSON 格式,配合 env:VAR / file:path 占位符语法运行时替换
  • mergeDeep + 数组拼接:基于 remeda 的深度合并,plugins / instructions 等数组字段拼接而非覆盖
  • 全量热重载:配置变更时 Instance.dispose()Instance.state(),销毁-重建确保一致性

Config 同样基于 Effect.ts 架构,通过 Config.Service 暴露服务接口,InstanceState 管理每个项目的配置生命周期。

关键文件

文件路径行数职责
src/config/config.ts~2600模块主文件:所有 Zod Schema 定义、六层加载逻辑、mergeDeep 合并、变量替换、Service 定义
src/config/paths.ts~30ConfigPaths 辅助路径:全局/项目级配置文件的路径计算
src/config/markdown.ts~20ConfigMarkdown 类型:Markdown 渲染相关配置
src/config/tui-schema.ts~50TUI 配置 Schema:快捷键绑定、主题、布局等 UI 配置的 Zod 定义
src/config/tui.ts~100TUI 配置逻辑:TUI 相关配置的加载与处理
src/config/tui-migrate.ts~60TUI 配置迁移:旧版本配置格式到新格式的自动迁移
src/config/console-state.ts~20ConsoleState 类型:控制台输出状态管理
.opencode/config.json项目级配置文件(可提交 Git)
~/.config/opencode/config.json用户全局配置

类型体系

Permission Schema — 14+ 种操作类型

权限操作类型是 Config 中最核心的 Schema 之一,定义了 Agent 可以执行的所有操作类别:

// 权限操作的三态枚举
const PermissionAction = z.enum(["ask", "allow", "deny"]);

// 权限规则:14+ 种操作的独立控制
const Permission = z.object({
  read: PermissionAction.default("allow"),               // 读取文件
  edit: PermissionAction.default("ask"),                 // 编辑文件
  bash: PermissionAction.default("ask"),                 // 执行 Shell 命令
  glob: PermissionAction.default("allow"),               // 文件模式搜索
  grep: PermissionAction.default("allow"),               // 文件内容搜索
  list: PermissionAction.default("allow"),               // 目录列表
  task: PermissionAction.default("allow"),               // 子任务执行
  external_directory: PermissionAction.default("ask"),   // 访问项目外目录
  todowrite: PermissionAction.default("ask"),            // 写入 TODO
  todoread: PermissionAction.default("allow"),           // 读取 TODO
  question: PermissionAction.default("ask"),             // 向用户提问
  webfetch: PermissionAction.default("ask"),             // 网络请求
  websearch: PermissionAction.default("ask"),            // 网络搜索
  codesearch: PermissionAction.default("ask"),           // 代码搜索
  lsp: PermissionAction.default("ask"),                  // LSP 操作
  doom_loop: PermissionAction.default("ask"),            // Doom Loop 确认
}).catchall(PermissionAction.default("ask"));

设计意图:默认策略是”读操作 allow、写操作 ask”,兼顾效率和安全。catchall 确保新增工具不会意外放行——任何未列出的操作类型默认走 ask 流程。

Provider Schema — 模型供应商配置

const Provider = z.object({
  apiKey: z.string().optional(),          // API 密钥(支持 {env:} 替换)
  baseURL: z.string().optional(),         // 自定义 API 端点
  disabled: z.boolean().optional(),       // 是否禁用该 Provider
  models: z.record(z.string(), z.object({
    // 模型级别的覆盖配置
    disabled: z.boolean().optional(),
    // ...
  })).optional(),
});

Mcp Schema — MCP 服务器配置

const Mcp = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("local"),             // 本地 MCP(stdio 模式)
    command: z.string(),                  // 启动命令,如 "npx"
    args: z.array(z.string()).optional(), // 命令参数
    env: z.record(z.string()).optional(), // 环境变量
    disabled: z.boolean().optional(),
  }),
  z.object({
    type: z.literal("remote"),            // 远程 MCP(SSE 模式)
    url: z.string(),                      // SSE 端点 URL
    headers: z.record(z.string()).optional(),
    disabled: z.boolean().optional(),
  }),
]);

discriminatedUnion 让两种 MCP 模式的类型安全共存——TypeScript 能根据 type 字段自动收窄剩余字段。

Agent Schema — Agent 自定义配置

const Agent = z.object({
  model: z.string().optional(),           // 绑定模型
  prompt: z.string().optional(),          // 自定义系统提示
  permission: Permission.optional(),      // 覆盖权限规则
  temperature: z.number().optional(),     // 温度参数
  topP: z.number().optional(),            // top_p 参数
  disabled: z.boolean().optional(),       // 是否禁用
  steps: z.number().int().positive().optional(), // 最大步数
});

Command Schema — 自定义命令

const Command = z.object({
  description: z.string().optional(),     // 命令描述
  agent: z.string().optional(),           // 指定执行 Agent
  template: z.string(),                   // 命令模板文本
  subtask: z.boolean().optional(),        // 是否子任务模式
});

Info — 顶层聚合 Schema

所有子配置汇聚到顶层 Info Schema:

const Info = z.object({
  model: ModelSchema.optional(),          // 默认模型选择
  provider: z.record(Provider).optional(), // Provider 配置字典
  agent: z.record(Agent).optional(),      // 自定义 Agent 配置
  mcp: z.record(Mcp).optional(),          // MCP 服务器配置
  permission: Permission.optional(),      // 全局权限规则
  command: z.array(Command).optional(),   // 自定义命令列表
  keybinds: Keybinds.optional(),          // 快捷键绑定
  tui: TUI.optional(),                    // TUI 配置
  server: Server.optional(),              // 服务器配置
  // ... 其他字段
});

每个子 Schema 都使用 .default() 提供回退值,确保任何配置项缺失时系统仍可正常运行。Info.parse() 是配置加载链的最后一道关卡——任何不符合 Schema 的配置都会在此时被拦截并给出清晰的错误提示。

核心流程

六层配置加载优先级

Config 的加载不是简单的”文件覆盖文件”,而是一套六层发现-合并流程(优先级从低到高):

第 1 层:内置默认值
  │  Schema 中 .default() 定义的值

第 2 层:全局配置文件
  │  ~/.config/opencode/config.json
  │  ~/.config/opencode/opencode.json
  │  ~/.config/opencode/opencode.jsonc

第 3 层:OPENCODE_CONFIG 环境变量
  │  环境变量指定的配置文件路径
  │  适合 CI/CD 场景或临时覆盖

第 4 层:项目配置(findUp)
  │  从当前目录向上查找 opencode.jsonc / opencode.json
  │  可提交 Git,团队成员共享

第 5 层:.opencode/ 目录
  │  项目内 .opencode/config.json
  │  不提交 Git(应在 .gitignore 中),个人偏好

第 6 层:OPENCODE_CONFIG_CONTENT 环境变量
  │  环境变量直接传入 JSON 内容(优先级最高)
  │  适合容器化部署,避免挂载配置文件

注意:CLI flag 不在合并链中。命令行参数在调用侧直接覆盖,而非参与 mergeDeep。例如 --model anthropic/claude-sonnet-4 会在 SessionPrompt 层直接覆盖 Config 的模型选择。

加载与解析:JSONC + 变量替换

load() 函数处理单个配置文件的完整加载流程:

load(filePath)
  ├─ 读取文件内容(Bun.file().text())
  ├─ JSONC 解析
  │   └─ jsonc-parser 的 parseJsonC()
  │      支持单行注释 //、多行注释 /* */、尾逗号
  ├─ 变量替换(递归遍历所有字符串值)
  │   ├─ {env:VAR} → process.env.VAR
  │   │   例:{env:OPENAI_API_KEY} → sk-xxxxx
  │   ├─ {file:path} → 读取外部文件内容
  │   │   例:{file:./prompts/system.txt} → 文件内容字符串
  │   └─ 未匹配的环境变量 → 保留原字符串并输出警告
  └─ 返回解析后的原始对象(尚未经过 Zod 验证)

{env:VAR} 替换让 API 密钥等敏感信息可以留在环境变量中,而不是明文写入配置文件。{file:path} 支持引入外部文件(如长 prompt 模板),保持配置文件简洁。变量替换在 Zod 验证之前执行,这样 Schema 可以对替换后的实际值进行类型校验。

合并策略:mergeDeep + 数组拼接

多层配置合并使用 remedamergeDeep,但针对特定字段做了数组拼接的增强:

function mergeConfigConcatArrays(base: Info, ...overrides: Info[]): Info {
  // 基于 remeda.mergeDeep 的深度合并
  // 特殊行为:
  //   - plugins、instructions、command 等数组字段 → 拼接而非覆盖
  //   - provider、mcp、agent 等字典字段 → 深度合并
  //   - model、temperature 等简单值 → 后者覆盖前者
  return overrides.reduce((acc, override) => {
    return customMerge(acc, override)
  }, base)
}

合并行为总结:

字段类型合并策略示例
简单值(model, temperature后者覆盖前者项目 model 覆盖全局 model
字典(provider, mcp, agent深度合并,按 key 合并全局 provider.openai + 项目 provider.openai → 合并
数组(plugins, instructions, command拼接,不覆盖全局 plugins + 项目 plugins = 合并列表

这保证了项目级 plugins 不会覆盖用户级 plugins,而是合并,两种配置可以互补。

插件去重:deduplicatePlugins

合并后可能存在重复插件(全局配置和项目配置都声明了同一个插件)。deduplicatePlugins() 按 canonical name 去重:

function deduplicatePlugins(plugins: Plugin[]): Plugin[] {
  // 按 canonical name 分组
  // 每组保留优先级最高的那个(即后加载的配置源中的插件)
  // canonical name:去除版本号、去除 scope 的包名
  // 例:@scope/my-plugin@1.0.0 和 @scope/my-plugin@2.0.0 视为同一个
  const seen = new Map<string, Plugin>()
  for (const plugin of plugins) {
    const canonical = toCanonical(plugin.name)
    seen.set(canonical, plugin)  // 后写入的覆盖先写入的
  }
  return [...seen.values()]
}

去重规则:高优先级配置源中的插件胜出。因为 plugins 数组是按优先级从低到高拼接的,所以后出现的同名插件自然覆盖先出现的。

Config.Service — Effect 服务架构

Config 模块通过 Effect Service 暴露接口:

export class Service extends Effect.Service<Service>("Config.Service")(
  undefined, // Service 上下文定义
  () =>
    Effect.gen(function* () {
      // 初始化配置状态
      const cfg = yield* loadConfig()
      return {
        get: () => cfg,
        update: (newConfig) => updateConfig(newConfig),
        // ...
      }
    }),
) {}

上层模块通过 Effect.provide(Service.Default) 注入配置服务,Config.get() 获取当前配置。Effect 的依赖注入让配置在测试中可以被轻松替换。

InstanceState 缓存

Config 的加载结果通过 Instance.state() 缓存,与项目实例的生命周期绑定:

const state = Instance.state(async () => {
  // 六层配置加载 + 合并 + 验证
  return await loadAllLayers()
})

export async function get() {
  return state()  // 首次调用时执行加载,后续返回缓存
}

Instance.state() 返回一个异步函数,首次调用时执行工厂函数并缓存结果。缓存在 Instance.dispose() 时自动清理——这意味着配置更新时的”销毁-重建”会触发重新加载。

配置更新与全量热重载

update() 函数实现配置的热更新,采用”销毁-重建”策略:

update(newConfig)
  ├─ JSON.stringify(newConfig, null, 2)
  ├─ fs.writeFile(projectConfigPath, content)
  │  └─ 写入项目级 .opencode/config.json
  ├─ Instance.dispose(currentState)
  │  └─ 销毁当前实例的所有缓存
  │     ├─ Config state 缓存清理
  │     ├─ Agent state 缓存清理
  │     ├─ Provider state 缓存清理
  │     └─ MCP 连接关闭
  └─ Instance.state()
     └─ 重新加载,触发完整初始化链:
        ├─ 加载所有配置层(六层)
        ├─ mergeDeep 合并
        ├─ deduplicatePlugins 去重
        ├─ Zod Schema 验证
        ├─ 安装依赖(plugins)
        ├─ 加载命令/agent/mode/plugin(通过 glob 模式扫描)
        └─ 构建新的 InstanceState

设计权衡:没有实现增量更新,而是”销毁-重建”。简单可靠,但重载期间有短暂的不可用窗口。在实际使用中,这个窗口非常短(通常少于 100ms),因为配置加载本身只是文件读取和 Schema 验证。

TUI 配置与迁移

TUI(Terminal UI)配置有独立的 Schema 和迁移逻辑,支持从旧版本自动升级:

// tui-migrate.ts — 旧版配置自动迁移
function migrate(oldConfig: unknown): TUIConfig {
  // 检测旧版格式并转换为新格式
  // 例:旧版的 keybind 字段名映射到新版的 keybinds
  // 迁移后自动保存,用户无感知
}

TUI 配置包括快捷键绑定、主题颜色、面板布局等 UI 细节。独立出来是因为 TUI 配置变更频繁,且与核心业务配置(模型、权限)的生命周期不同。

调用链示例

链路 1:项目启动时加载配置

用户在项目目录运行 opencode


Instance.state()  // 首次调用,触发配置加载

  ├─ loadBuiltinDefaults()
  │   └─ 返回 Schema 中 .default() 定义的内置默认值

  ├─ loadGlobalConfig()
  │   └─ ConfigPaths.global() → ~/.config/opencode/
  │   └─ 尝试读取 config.json / opencode.json / opencode.jsonc(按优先级)
  │   └─ load(globalConfigPath) → JSONC 解析 + 变量替换

  ├─ loadEnvConfig()
  │   └─ process.env.OPENCODE_CONFIG → 指定路径的配置文件
  │   └─ 若存在 → load(envConfigPath)

  ├─ loadProjectConfig()
  │   └─ findUp("opencode.jsonc", "opencode.json")
  │   └─ 从 cwd 向上查找,找到第一个即停止
  │   └─ load(projectConfigPath)

  ├─ loadOpencodeDir()
  │   └─ .opencode/config.json
  │   └─ load(opencodeDirPath)

  ├─ loadEnvContent()
  │   └─ process.env.OPENCODE_CONFIG_CONTENT → 直接解析 JSON

  ├─ mergeConfigConcatArrays(defaults, global, env, project, opencodeDir, envContent)
  │   └─ 六层按优先级从低到高合并
  │   └─ 数组字段拼接、字典字段深度合并、简单值覆盖

  ├─ deduplicatePlugins(merged.plugins)
  │   └─ 按 canonical name 去重

  ├─ Info.parse(result)
  │   └─ Zod Schema 验证,类型安全的配置对象

  └─ 存入 InstanceState 缓存

链路 2:环境变量替换 API 密钥

配置文件 opencode.jsonc:
{
  "provider": {
    "openai": {
      "apiKey": "{env:OPENAI_API_KEY}",  // 占位符
      "baseURL": "{env:CUSTOM_OPENAI_URL}"
    }
  }
}


load(filePath)
  ├─ 读取文件内容
  ├─ JSONC.parse(content)
  │   └─ 支持 // 和 /* */ 注释
  │   └─ 结果: { provider: { openai: { apiKey: "{env:OPENAI_API_KEY}", ... } } }
  ├─ 变量替换(递归遍历所有字符串值)
  │   ├─ "{env:OPENAI_API_KEY}" → process.env.OPENAI_API_KEY → "sk-xxxxx"
  │   ├─ "{env:CUSTOM_OPENAI_URL}" → process.env.CUSTOM_OPENAI_URL → "https://..."
  │   └─ 结果: { provider: { openai: { apiKey: "sk-xxxxx", baseURL: "https://..." } } }
  ├─ Provider Schema 验证
  └─ 运行时 apiKey 为实际值,配置文件中无明文密钥

链路 3:配置变更传播(全量重载)

用户在 TUI 修改模型选择(从 claude-sonnet-4 → gpt-5)


Config.update({ model: "openai/gpt-5" })

  ├─ 读取当前项目配置
  ├─ 合并新值到配置对象
  ├─ fs.writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2))

  ├─ Instance.dispose(currentState)
  │   ├─ 清理 Config state 缓存
  │   ├─ 清理 Provider state 缓存(关闭旧的 SDK 实例)
  │   ├─ 清理 Agent state 缓存
  │   ├─ 关闭 MCP 服务器连接
  │   └─ 广播 Instance disposed 事件

  └─ Instance.state() → 触发完整的重新初始化
     ├─ 重新加载六层配置
     ├─ Provider 初始化:新的模型 gpt-5 注册到 Provider.Info
     ├─ Agent 初始化:读取更新后的配置
     ├─ MCP 重连:按新配置启动 MCP 服务器
     └─ 所有依赖 Config 的模块获得新配置

链路 4:全局 + 项目配置合并

全局配置 ~/.config/opencode/config.json:
{
  "provider": {
    "anthropic": { "apiKey": "{env:ANTHROPIC_API_KEY}" },
    "openai": { "apiKey": "{env:OPENAI_API_KEY}" }
  },
  "plugins": ["plugin-a", "plugin-b"],
  "permission": {
    "bash": "ask"    // 全局默认:bash 需要 ask
  }
}

项目配置 ./opencode.jsonc:
{
  "provider": {
    "anthropic": {
      "disabled": true   // 项目禁用 Anthropic
    }
  },
  "plugins": ["plugin-c"],
  "permission": {
    "bash": "allow",     // 项目覆盖:bash 自动放行
    "edit": "allow"      // 项目覆盖:edit 自动放行
  }
}


合并结果:
{
  provider: {
    anthropic: { apiKey: "sk-xxx", disabled: true },  // 深度合并
    openai: { apiKey: "sk-yyy" }
  },
  plugins: ["plugin-a", "plugin-b", "plugin-c"],      // 数组拼接
  permission: {
    bash: "allow",     // 项目覆盖全局
    edit: "allow"      // 项目新增
  }
}

链路 5:外部文件引入(file:path 占位符替换)

配置文件 opencode.jsonc:
{
  "agent": {
    "custom-reviewer": {
      "prompt": "{file:./prompts/review-prompt.md}",  // 引入外部文件
    }
  }
}

文件 ./prompts/review-prompt.md 内容:
你是一个专业的代码审查员。请检查以下方面的内容:
1. 安全漏洞
2. 性能问题
3. 代码风格一致性
...


变量替换后:
{
  agent: {
    "custom-reviewer": {
      prompt: "你是一个专业的代码审查员。请检查以下方面的内容:\n1. 安全漏洞\n2. 性能问题\n3. 代码风格一致性\n..."
    }
  }
}

设计取舍

决策理由
JSONC 而非 YAMLJSONC 在保持 JSON 兼容性的同时支持注释,工具链生态更好(IDE 语法高亮、JSONC parser 成熟)。YAML 的缩进敏感和隐式类型转换容易引入 bug
全量重载 vs 增量更新选择全量重载,牺牲了重载性能,但避免了复杂的状态同步问题。增量更新需要跟踪”哪些模块依赖哪些配置项”,在模块间耦合松散的架构中维护成本极高
Zod Schema 驱动相比手动类型定义,Zod 同时提供运行时验证和编译时类型推导,一套 Schema 双重保障。z.infer<typeof Schema> 自动从 Schema 推导 TypeScript 类型
数组拼接而非覆盖plugins/instructions/command 等字段的拼接行为让全局和项目配置可以互补,而非互斥。如果用覆盖,用户需要在项目配置中重复声明全局已有内容
six 层配置而非无限继承六层优先级覆盖了常见的使用场景(默认值、全局、CI、团队共享、个人偏好、容器),更多层次会增加理解成本和调试难度
findUp 向上查找从 cwd 向上查找配置文件,支持 monorepo 中子目录运行 opencode 时自动找到根目录的配置
catchall 默认 ask新增工具类型不会因为缺少显式配置而意外放行,安全优先
InstanceState 缓存配置加载涉及多层文件读取和 Schema 验证,缓存避免重复开销。缓存生命周期与项目实例绑定,切换项目时自动失效

与其他模块的关系

  • Provider:Provider 初始化时从 Config 读取模型配置和 API 密钥(经 {env:VAR} 变量替换后的实际值)。Config.get().provider 提供 Provider 注册和认证信息
  • Permission:权限规则通过 Permission Schema 定义在 Config 中,Permission.fromConfig() 将配置解析为运行时 Ruleset。Config 的 catchall 默认值确保新工具类型安全
  • Agent:Agent 的行为参数(最大循环次数、默认 model、温度、权限覆盖)从 Config 的 agent 字段读取。Agent 列表通过 Instance.state() 缓存
  • MCP:MCP 服务器列表(mcp 字段)和配置来自 Config,支持 local(stdio)和 remote(SSE)两种模式。MCP 配置变更触发重连
  • Command:自定义命令通过 Config 的 command 数组定义,与内置命令合并后注册到 Command 模块
  • CLI / TUI:TUI 的快捷键绑定(keybinds)、主题(tui)等 UI 配置来自 Config。TUI 配置有独立的迁移逻辑
  • Session:InstanceState 与 Session 共享生命周期,配置变更触发 Session 级别的重载。Session 在初始化时读取 Config 确定模型和 Agent 配置
  • Plugin:Plugin 列表从 Config 加载,deduplicatePlugins() 确保同一插件只加载一次。Plugin 的启用/禁用通过配置控制
  • Instance:Config 的缓存通过 Instance.state() 管理,Instance.dispose() 时自动清理。配置更新触发完整的实例重建