跳转到内容

CLI 源码解读

模块概述

CLI 是 OpenCode 的命令行入口层,位于 packages/opencode/src/cli/。TypeScript 版本使用 Yargs(而非 Go 版本的 Cobra)解析命令行参数和子命令,支持两种运行模式:

  • TUI 模式(默认):启动 Ink(基于 React 的终端 UI 渲染引擎),后台 Worker 线程通过 SSE 事件流与 Server 通信,处理 Agent 交互和工具执行
  • 非交互模式run 子命令):单次 Prompt 执行后退出,基于 @opencode-ai/sdk v2 的 OpencodeClient 实现完整的 Agent 会话,包含 20+ 种工具的内联渲染

与 Go 版本的根本差异在于:Go 版本在进程内直接持有 App 服务实例,而 TS 版本通过 SSE 与独立的 Server 进程通信——CLI 是纯粹的客户端,Server 承载所有业务逻辑。这种架构使得 CLI 可以远程连接到 opencode serve 启动的服务器,实现”本地编辑、远端推理”的工作模式。

核心职责:

  • 命令行参数解析与子命令路由(Yargs 框架)
  • 项目实例初始化(bootstrapInstance.provide
  • SSE 事件流建立与双向通信
  • 非交互模式下的工具状态渲染(20+ 种工具类型)
  • 子命令体系管理(session、agent、mcp、models、providers、serve 等)

关键文件

文件路径行数职责
src/cli/bootstrap.ts~17项目初始化包装器:调用 Instance.provide 设置项目目录和配置
src/cli/cmd/cmd.ts~6cmd() 辅助函数:封装 Yargs CommandModule 的类型安全包装
src/cli/cmd/run.ts~690run 子命令:非交互模式核心实现,SSE 事件流消费、工具渲染、文件附加、权限处理
src/cli/cmd/tui/worker.ts~175TUI Worker:SSE 连接管理、Agent 会话代理、事件转发到 Ink UI
src/cli/cmd/tui/event.tsTUI BusEvent 定义:Worker 与 Ink UI 之间的事件协议
src/cli/cmd/session.tssession 子命令:会话的 CRUD、分享、归档操作
src/cli/cmd/agent.tsagent 子命令:Agent 列表查询与管理
src/cli/cmd/mcp.tsmcp 子命令:MCP 服务器管理
src/cli/cmd/models.tsmodels 子命令:列出可用模型
src/cli/cmd/providers.tsproviders 子命令:列出可用 Provider
src/cli/cmd/serve.tsserve 子命令:启动本地 HTTP 服务器
src/cli/cmd/debug/debug 子命令组:调试工具集合
src/cli/ui.ts终端 UI 辅助:Style 常量、inline()/block() 格式化函数
src/cli/logo.tsLogo ASCII art:启动时渲染品牌标识
src/cli/heap.ts堆快照:writeHeapSnapshot() 用于内存分析
src/cli/upgrade.ts自动升级:检测并安装新版本
src/cli/network.ts网络检测:连通性检查与超时处理
src/cli/error.ts错误处理:全局异常捕获和格式化输出

类型体系

UI.Style — 终端样式常量

ui.ts 定义了统一的终端输出样式常量,确保所有子命令的输出风格一致:

export const Style = {
  TEXT_NORMAL,       // 默认文本
  TEXT_DIM,          // 灰色弱化文本(辅助信息)
  TEXT_INFO_BOLD,    // 蓝色加粗(标题、状态)
  TEXT_WARNING_BOLD, // 黄色加粗(警告)
  TEXT_DANGER_BOLD,  // 红色加粗(错误、拒绝)
} as const

UI 输出函数

// 单行信息展示:icon + 标题 + 描述
export function inline(info: {
  icon: string
  title: string
  description?: string
}): void

// 带分隔线的信息块:标题 + 可选输出内容
export function block(info: {
  icon: string
  title: string
  description?: string
}, output?: string): void

inline() 用于紧凑的状态提示(如 “Agent: build”),block() 用于需要展示详细输出内容的场景(如工具执行结果)。

cmd() 辅助函数

// cmd.ts — 类型安全的 Yargs CommandModule 包装
export function cmd(mod: CommandModule): CommandModule {
  return mod
}

虽然只有 6 行代码,cmd() 函数的价值在于为所有子命令提供统一的类型签名约束——每个子命令模块通过 cmd() 导出,确保 Yargs 的 CommandModule 接口被正确实现。

核心流程

入口与命令路由

CLI 入口位于 packages/opencode/src/node.ts,它使用 Yargs 定义完整的子命令体系:

opencode                    # 主入口(无参数 → 启动 TUI 模式)
  ├── run [message]          # 非交互模式
  │   ├── --continue, -c     # 继续上次会话
  │   ├── --session, -s      # 指定会话 ID
  │   ├── --fork             # 从已有会话分叉
  │   ├── --model, -m        # 指定模型(provider/model 格式)
  │   ├── --agent            # 指定执行 Agent
  │   ├── --format           # 输出格式(default / json)
  │   ├── --file, -f         # 附加文件到上下文
  │   ├── --attach           # 连接到远程服务器
  │   ├── --thinking         # 显示推理过程
  │   └── --dangerously-skip-permissions  # 跳过权限确认
  ├── session                # 会话管理
  │   ├── list               # 列出所有会话
  │   ├── create             # 创建新会话
  │   ├── delete             # 删除会话
  │   ├── share              # 分享会话
  │   └── archive            # 归档会话
  ├── agent                  # Agent 管理(列出内置和自定义 Agent)
  ├── mcp                    # MCP 服务器管理
  ├── models                 # 模型列表(按 Provider 分组展示)
  ├── providers              # Provider 列表
  ├── serve                  # 启动本地 HTTP 服务器
  ├── config                 # 配置管理
  └── debug                  # 调试工具组

每个子命令通过 cmd() 包装后注册到 Yargs,例如 session 子命令内部使用嵌套的 Yargs builder 定义 listcreatedeletesharearchive 五个二级子命令。

项目初始化:bootstrap()

bootstrap.ts 是所有需要项目上下文的子命令的公共前置步骤:

// 简化的 bootstrap 实现
export async function bootstrap(
  dir: string,       // 项目工作目录
  cb: () => Promise<void>  // 初始化完成后的回调
) {
  await Instance.provide({ directory: dir })
  await cb()
}

Instance.provide 根据给定目录初始化完整的项目实例——加载配置、连接 Storage、注册 Provider、发现 MCP 服务器等。bootstrap() 将这个初始化过程封装为统一入口,子命令只需关心自己的业务逻辑。

非交互模式:run 命令详解

run.ts(~690 行)是 CLI 模块中最复杂的文件,实现了完整的非交互式 Agent 会话。核心流程分为四个阶段:

阶段一:初始化与会话准备

// 简化的初始化序列
await bootstrap(dir, async () => {
  // 1. 解析命令行参数
  const message = argv._[0] as string           // 用户消息
  const sessionID = argv.session                 // 指定会话 ID
  const continue_ = argv.continue                // 继续上次会话
  const fork = argv.fork                         // 分叉会话
  const model = argv.model                       // 模型选择
  const agent = argv.agent                       // Agent 选择
  const files = argv.file as string[] ?? []      // 附加文件
  const format = argv.format ?? "default"        // 输出格式

  // 2. 创建 SSE 客户端连接
  const client = new OpencodeClient(/* ... */)
})

阶段二:文件附加与会话分叉

run 命令支持通过 --file 参数将文件内容注入到用户消息中:

// 文件附加逻辑:将文件内容作为消息上下文
const fileParts: Part[] = []
for (const filePath of files) {
  const content = await readFile(filePath, "utf-8")
  fileParts.push({
    type: "text",
    text: `--- ${filePath} ---\n${content}`,
  })
}
// 文件内容拼接到用户消息之前

会话分叉机制允许用户从已有会话创建新分支:

  • --fork <sessionID>:基于指定会话的消息历史创建新会话
  • --fork + --continue:分叉后继续在新会话中对话
  • --continue(无 --fork):直接继续最近一次会话

阶段三:SSE 事件流消费与工具渲染

这是 run 命令的核心——通过 SSE 连接实时接收 Agent 的流式响应,并根据事件类型调用对应的渲染函数:

// 事件流消费的主循环(简化)
for await (const event of client.events()) {
  switch (event.type) {
    case "message":
      // 文本输出 → 直接打印到终端
      process.stdout.write(event.content)
      break

    case "tool":
      // 工具事件 → 调用对应的渲染函数
      renderTool(event.tool, event.state)
      break

    case "error":
      // 错误 → 格式化输出
      UI.inline({
        icon: "✗",
        title: event.name,
        description: event.message,
      })
      break

    case "permission":
      // 权限请求 → 非交互模式默认拒绝
      if (!dangerouslySkipPermissions) {
        UI.inline({
          icon: "🔒",
          title: "Permission denied",
          description: event.tool,
        })
      }
      break
  }
}

阶段四:输出格式化

run 命令支持两种输出格式:

  • --format default:人类可读的终端输出,使用 UI.inline()UI.block() 格式化工具状态
  • --format json:结构化的 JSON 事件流,每行一个 JSON 对象,适用于脚本集成和 CI/CD 场景

工具渲染系统

run.ts 包含 20+ 种工具类型的专用渲染函数,每种工具根据其语义展示不同的信息:

// glob 工具:显示搜索模式、匹配数、根目录
function renderGlob(tool: ToolEvent) {
  UI.inline({
    icon: "📁",
    title: `glob: ${tool.input.pattern}`,
    description: `${tool.matchCount} matches in ${tool.input.root}`,
  })
}

// grep 工具:显示搜索模式、匹配数
function renderGrep(tool: ToolEvent) {
  UI.inline({
    icon: "🔍",
    title: `grep: ${tool.input.pattern}`,
    description: `${tool.matchCount} matches`,
  })
}

// read 工具:显示文件路径和参数
function renderRead(tool: ToolEvent) {
  UI.inline({
    icon: "📄",
    title: `read: ${tool.input.filePath}`,
    description: tool.input.offset ? `lines ${tool.input.offset}-${tool.input.offset + tool.input.limit}` : "",
  })
}

// edit/write 工具:diff 展示
function renderEdit(tool: ToolEvent) {
  UI.block({
    icon: "✏️",
    title: `edit: ${tool.input.filePath}`,
  }, tool.diff)  // 展示具体的 diff 内容
}

// bash 工具:命令 + 输出
function renderBash(tool: ToolEvent) {
  UI.block({
    icon: "⚡",
    title: tool.input.command,
  }, tool.output)
}

// task 工具:子 Agent 任务状态
function renderTask(tool: ToolEvent) {
  UI.inline({
    icon: "🤖",
    title: `task: ${tool.input.description}`,
    description: tool.state,  // pending / running / completed / error
  })
}

此外还有 webfetchcodesearchwebsearchskilltodo 等工具的渲染函数。对于未识别的工具类型,使用 fallback 兜底展示——打印工具名称和 JSON 序列化的输入参数。

TUI 模式:Worker 架构详解

当用户不带任何参数运行 opencode 时,进入 TUI 模式。TS 版本的 TUI 架构与 Go 版本有根本差异:

  • Go 版本:进程内直接持有 *app.App,通过 Bubble Tea 的 program.Send() 注入事件
  • TS 版本:UI 进程(Ink/React)和 Server 进程分离,Worker 通过 SSE 桥接两者

Worker 核心逻辑cmd/tui/worker.ts,~175 行):

// Worker 启动序列(简化)
async function startWorker() {
  // 1. 初始化项目实例
  await Instance.provide({ directory: workdir })

  // 2. 建立 SSE 事件流连接
  const eventStream = await startEventStream(serverURL)

  // 3. 订阅所有 Bus 事件并转发到 UI
  Bus.subscribeAll((event) => {
    Rpc.emit(event.type, event.data)  // 通过 RPC 转发到 Ink 渲染线程
  })

  // 4. 启动 Agent 会话
  // ... 会话管理逻辑
}

事件流桥接机制

Server 进程                    Worker 线程                    Ink UI(React)
    │                              │                              │
    │  Bus.publish(event)          │                              │
    │ ──────────────────────────> │                              │
    │                              │  Rpc.emit(eventType, data)   │
    │                              │ ──────────────────────────> │
    │                              │                              │  React 组件更新
    │                              │                              │  重新渲染终端

Worker 通过 Bus.subscribeAll() 订阅所有事件(消息更新、工具状态、Session 变更等),然后通过 Rpc.emit() 转发到 Ink 渲染线程。Ink 组件通过 Rpc.on() 监听事件并触发 React 状态更新。

自动重连机制

// SSE 连接断开时的自动重连
eventStream.on("close", () => {
  setTimeout(() => {
    startEventStream(serverURL)  // 250ms 延迟后重试
  }, 250)
})

Worker 在 SSE 连接断开后等待 250ms 自动重连。这个延迟避免了在网络抖动时的重连风暴,同时足够短以保持用户体验的流畅性。

实例清理

// 监听实例销毁事件,触发资源清理
Bus.subscribe(Bus.InstanceDisposed, () => {
  settle()  // 完成所有进行中的操作
  Instance.disposeAll()  // 清理所有资源
})

当项目实例被销毁时(如切换工作目录),Worker 清理所有进行中的操作、关闭 SSE 连接、释放 Storage 和 Provider 资源。

错误处理

CLI 层面的错误处理覆盖三个层级:

1. 全局异常捕获

// error.ts — 进程级异常兜底
process.on("unhandledRejection", (error) => {
  UI.inline({
    icon: "✗",
    title: "Unexpected error",
    description: String(error),
  })
  process.exit(1)
})

process.on("uncaughtException", (error) => {
  UI.inline({
    icon: "✗",
    title: "Uncaught exception",
    description: String(error),
  })
  process.exit(1)
})

2. Session 错误事件

// run.ts — Session 级错误处理
Bus.subscribe(Session.error, (event) => {
  const { name, message } = extractError(event.error)
  UI.inline({
    icon: "✗",
    title: name,
    description: message,
  })
})

3. 权限处理

在非交互模式下,因为没有用户在场确认权限请求,默认策略是自动拒绝所有权限请求:

// run.ts — 权限事件处理
Bus.subscribe(Permission.asked, (event) => {
  if (dangerouslySkipPermissions) {
    // --dangerously-skip-permissions 标志下自动批准
    event.respond(true)
  } else {
    // 默认:自动拒绝
    event.respond(false)
  }
})

--dangerously-skip-permissions 标志适用于 CI/CD 等完全自动化的场景,跳过所有权限确认。标志名称中的 “dangerously” 前缀明确提示此操作的风险。

Heap 快照

heap.ts 提供内存分析支持:

// heap.ts — 启动时自动写入堆快照
import { writeHeapSnapshot } from "node:v8"

// 写入堆快照到项目目录
writeHeapSnapshot(path.join(workdir, "server.heapsnapshot"))

堆快照用于排查内存泄漏问题。生成的 server.heapsnapshot 文件可以用 Chrome DevTools 的 Memory 面板加载分析。

调用链示例

链路 1:非交互模式 — 用户执行 opencode run "读取 main.ts"

用户执行: opencode run "读取 main.ts"


Yargs 解析参数 → 匹配 "run" 子命令


run.ts handler 执行:
    ├─ bootstrap(cwd, callback)
    │   └─ Instance.provide({ directory: cwd })
    │      ├─ 加载 Config(opencode.json)
    │      ├─ 初始化 Storage
    │      ├─ 注册 Provider(加载 API Key)
    │      └─ 发现 MCP 服务器

    ├─ 创建或恢复 Session
    │   ├─ --continue? → Session.get(recent)
    │   ├─ --fork?     → Session.create({ parentID: fork })
    │   └─ 默认         → Session.create()

    ├─ 构造用户消息
    │   ├─ text: "读取 main.ts"
    │   ├─ --file 附加? → 追加文件内容
    │   └ --model? → 覆盖默认模型

    ├─ 创建 OpencodeClient → SSE 连接到 Server

    └─ 事件流消费循环:
        ├─ message.text   → process.stdout.write()
        ├─ tool.read      → renderRead({ filePath, offset, limit })
        ├─ tool.read.done → block("edit", diff)
        ├─ tool.bash      → block("⚡", command + output)
        ├─ error          → inline("✗", error.name, message)
        └─ session.end    → break (退出循环)

进程退出,输出结果到 stdout

链路 2:TUI 模式 — Worker 事件转发

用户运行: opencode(无参数)


Yargs → 无匹配子命令 → 启动 TUI 模式


Ink 渲染引擎启动(React 组件树)


Worker 线程启动:
    ├─ Instance.provide({ directory: cwd })
    ├─ startEventStream(serverURL)
    │   └─ SSE 连接建立

    └─ Bus.subscribeAll() 注册全局事件监听


用户在 Ink UI 中输入消息:
    ├─ Rpc.call("session.prompt", { message })
    │   └─ Worker 接收 → 调用 Session.prompt()

    ├─ Server 处理消息:
    │   ├─ SessionPrompt.loop()
    │   ├─ LLM.stream() → Vercel AI SDK streamText()
    │   ├─ Bus.publish(MessageV2.Event.PartUpdated)
    │   └─ Bus.publish(MessageV2.Event.Updated)

    └─ Worker 事件转发:
        ├─ Bus → Rpc.emit("message.updated", data)
        │   └─ Ink 组件 setState() → 重新渲染消息列表

        └─ Bus → Rpc.emit("tool.state", data)
            └─ Ink 组件 setState() → 更新工具状态指示器

链路 3:会话分叉与继续

用户执行: opencode run "继续修改" --fork abc123 --model anthropic/claude-sonnet-4


run.ts 解析参数:
    ├─ message = "继续修改"
    ├─ fork = "abc123"
    ├─ model = { providerID: "anthropic", modelID: "claude-sonnet-4" }


Session.create({ parentID: "abc123" })
    ├─ 从父会话 abc123 复制消息历史
    ├─ 新会话获得独立 ID,不影响父会话


模型覆盖:
    └─ Provider.getModel("anthropic", "claude-sonnet-4")
       └─ 后续 LLM 调用使用指定模型


正常事件流消费(同链路 1)

设计取舍

决策理由
Yargs 而非 Commander / 自研Yargs 提供成熟的子命令嵌套、参数校验、自动帮助生成和类型安全的 builder 模式。子命令体系(session → list/create/delete/share/archive)的嵌套定义在 Yargs 中自然表达
SSE 而非进程内调用TS 版本的 CLI 与 Server 分离架构使得 CLI 可以远程连接到 opencode serve 启动的服务器。SSE 作为单向事件流的传输协议,配合 OpencodeClient 的 RPC 调用实现双向通信
Ink(React)而非 Bubble TeaGo 版本的 Bubble Tea 使用 Elm Architecture(Model-Update-View),TS 版本使用 Ink + React——React 的组件化模型更适合构建复杂的终端 UI,且开发者生态更大
非交互模式默认拒绝权限没有用户在场确认权限请求,自动拒绝是最安全的默认策略。--dangerously-skip-permissions 以显式的危险标志提供覆盖选项
20+ 工具类型各自渲染每种工具的语义不同(glob 展示匹配数、edit 展示 diff、bash 展示命令输出),专用渲染函数比通用渲染器提供更好的可读性。fallback 函数保证未识别工具也能正常展示
Worker 线程而非主线程SSE 事件流消费和 Bus 事件订阅在 Worker 线程执行,避免阻塞 Ink 的 React 渲染循环。Ink 通过 Rpc.emit/Rpc.on 与 Worker 通信,保持 UI 流畅
250ms 重连延迟网络抖动时避免重连风暴(太短则服务器压力过大),同时保持足够的响应速度(用户几乎感知不到 250ms 的中断)
bootstrap() 作为统一初始化入口所有需要项目上下文的子命令共享同一套初始化逻辑(Config 加载、Provider 注册、MCP 发现),避免重复代码和不一致状态

与其他模块的关系

  • Instancesrc/project/):bootstrap() 调用 Instance.provide() 初始化项目实例;Worker 调用 Instance.disposeAll() 清理资源。Instance 是 CLI 与业务逻辑之间的桥梁
  • Sessionsrc/session/):run 命令通过 OpencodeClient 操作 Session(创建、继续、分叉);session 子命令直接调用 Session 模块的 CRUD 接口
  • Agentsrc/agent/):run 命令的 --agent 参数指定执行 Agent;agent 子命令调用 Agent.list() 展示可用 Agent。CLI 本身不执行 Agent 逻辑,只做参数传递
  • Providersrc/provider/):run 命令的 --model 参数通过 Provider.getModel() 验证模型可用性;modelsproviders 子命令直接调用 Provider 查询接口
  • Bussrc/bus/):Worker 通过 Bus.subscribeAll() 订阅所有事件,桥接到 Ink UI;run 命令通过 Bus 监听 Session.errorPermission.asked 事件
  • MCPsrc/mcp/):mcp 子命令管理 MCP 服务器的启停;Instance.provide() 在初始化时自动发现和连接 MCP 服务器
  • Configsrc/config/):bootstrap() 初始化时加载 Config;config 子命令提供配置查看和修改接口。所有子命令的默认参数(模型、Agent 等)都来自 Config
  • Storagesrc/storage/):Session 的消息和状态通过 Storage 持久化,CLI 通过 OpencodeClient 间接操作
  • Serversrc/server/):serve 子命令启动本地 HTTP 服务器;TUI Worker 和 run 命令通过 SSE 连接到 Server 进行通信
  • UI 辅助cli/ui.tscli/logo.ts):所有子命令共享统一的终端输出格式,确保品牌一致性和可读性