
拆解 opencode 源码 · 第二章 CLI 入口与启动流程 · 总结篇如果你要设计一个 CLI 入口层你会先做什么大部分人从框架选型开始yargs 还是 commander.js但 opencode 的第二章揭示了另一个顺序——技术栈选型先于框架选型。因为整个 CLI 层的形状是由 Effect-ts 运行时决定的不是由 yargs 决定的。第二章拆了三个模块effectCmd 桥接、InstanceStore 生命周期、bootstrap 就绪协议但它们不是三个独立的设计决策而是一条因果链上的三个节点选择 Effect-ts 运行时 → 需要桥接 async/await 和 Effect 世界 → 桥接需要生命周期管理 → 生命周期管理的就绪部分自然形成了 bootstrap 的分层协议。本文不重复三篇正文的技术细节——它们已经在 02-01 到 02-03 里了。本文做三件事揭示这条因果链、提炼跨模块的设计权衡、定位第二章在整个 opencode 中的角色。因果链yargs → effectCmd → InstanceStore → bootstrap起点为什么选了 yargsCLI 框架选型不是一次独立的技术评审——它受到了上游技术栈的约束。opencode 的 CLI 层有 22 条子命令每条命令需要参数解析、类型推导、--分隔符支持。这些需求 yargs 和 commander.js 都能满足。真正的决定因素是谁对 Effect-ts 运行时友好。yargs 的CommandModule是一个纯对象接口它只约束{command, describe, builder, handler}四个字段。纯对象意味着可以包装——即在不改变 yargs 框架代码的前提下在 handler 外层包裹 Effect 运行时的初始化和释放逻辑。Commander.js 的.command(sub).action(handler)链式调用则更难插入中间层包装。所以 yargs 不是因为功能更强被选中的而是因为它的接口风格允许在外面套一层 effectCmd 而不侵入框架内部。这是一个细微但关键的差别框架选型有时不是因为框架本身的优劣而是因为它给非框架代码留了多少改造空间。传导effectCmd 的诞生意味着什么effectCmd的 27 行核心代码packages/opencode/src/cli/effect-cmd.ts:69-96做了三件事创建 Effect 运行时、注入 InstanceContext、确保 dispose。这三件事之所以被封装到一个函数里不是因为代码量27 行不值得封装而是因为这三件事必须同时发生、且只发生一次。这条规则导致了一个下游设计约束InstanceStore必须提供load()和dispose()两个对称接口而且load()必须是幂等的——同一个目录调用多次不会重复创建 InstanceContext。于是instance-store.ts内部维护了一个Mapstring, Entry缓存池用DeferredInstanceContext实现先到先 boot后到等结果的去重机制。汇聚bootstrap 就绪协议的成因bootstrap.runpackages/opencode/src/project/bootstrap.ts:32-46的三行代码不是设计者拍脑袋想出来的而是被 InstanceStore 的接口设计倒逼出来的。因为InstanceStore.load()必须返回一个InstanceContext而 InstanceContext 需要包含配置、插件、服务——所以 bootstrap 必须编排它们。因为编排的顺序有依赖plugin 可以改 config所以config.get()必须在plugin.init()之前。因为 6 个服务没有相互依赖所以它们可以并发 init。bootstrap.ts 只有 76 行——它本身不做任何初始化而是协调 8 个 ServiceConfig、Format、LSP、Plugin、Project、ShareNext、Snapshot、Vcs的组合。这种极简的编排层是因果链的终点框架选型 → 桥接层 → 生命周期管理 → 就绪协议每一个节点都是前一个节点的逻辑产物而不是独立的设计选择。两条跨模块的设计权衡权衡一桥接代码 vs 纯 async 方案如果 opencode 选择纯 async/await 运行时CLI 层可以去掉effectCmd、AppRuntime.runPromise、Effect.provideService三层桥接代码每条命令的 handler 直接写async (args) { ... }。opencode 没有这么做。代价是显性的每调用一次AppRuntime.runPromise都要创建完整的 Effect 运行时环境。但收益是隐性的20 条命令共享同一套 dispose 保障。手写 async handler 时每条命令的finally { dispose(ctx) }是一个记忆负担——只要有一条命令忘了写就在生产环境留下一个资源泄漏点。所以这条权衡的精确表述是用 27 行桥接代码加上每命令一行instance: true/false换了 20 条命令 × 5 行模板 100 行潜在风险代码的消除。这不是代码量上的胜负27 行 vs 100 行而是风险集中化的胜利——所有 dispose 逻辑在一处出错概率从 20 个点降到 1 个点。权衡二缓存粒度 vs 无状态 CLIInstanceStore选择了基于Mapdirectory, Entry的缓存池。这意味着同一个目录第二次调用load()不需要重新 bootstrap直接从DeferredInstanceContext中取结果。替代方案是无状态 CLI每次load()都重新构建一次 InstanceContext用完就丢。无状态方案代码更少不需要缓存 map、不需要去重逻辑但有两个硬伤第一同一目录的并发load()可能需要并行 boot 两次浪费第二Server 模式下需要为每个入站请求独立 bootstrap 和 dispose而缓存池允许跨请求共享同一个 InstanceContext。opencode 的 Server 功能opencode serve是这条权衡的关键因素——如果只有 CLI 模式无状态方案就够了。但 Server 模式下多个 HTTP 请求需要共享 InstanceContext缓存池就成了必要条件。第二章的全局定位核心产出不是配置不是插件是 InstanceRef把第二章的三个模块串起来看它们共同构建了一个核心产物InstanceRef。它是一个 Effect 上下文中的服务标签任何模块可以通过yield* InstanceRef拿到当前 InstanceContext 的引用。这个设计意味着opencode 的上下文传播不靠参数传递每个函数都传ctx不靠全局变量global.ctx而是靠 Effect-ts 的依赖注入系统。第二章的正文章节已经展示了这条链上的每个环节effectCmd通过Effect.provideService(InstanceRef, ctx)注入下游代码通过yield* InstanceState.context获取。如果一定要用一句话概括第二章做了什么那就是把一条process.argv中的字符串变成 Effect 上下文中的一个InstanceRef。被哪些后文章节消费章节消费的内容具体方式第三章 命令与工作流VCS、Project service6 个 bootstrap service 中的两个第四章 Agent 系统InstanceRefagent 创建时需要注入 InstanceRef第五章 Session 会话引擎InstanceContext.directory / projectsession 的元数据字段第六章 Tool 工具系统InstanceState.context所有工具通过此获取当前 instance没有第二章的 InstanceContext第三章的 VCS 不知道当前在哪个 git 仓库第四章的 agent 不知道用什么配置第五章的 session 不知道关联哪个项目第六章的工具不知道读哪个目录的文件。