跳转到内容

CLI / 启动入口

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度入门
预计阅读35 分钟
  • 章节 ID:01-cli-startup
  • 章节摘要:从一次带文件的 opencode run 出发,理解 bin、yargs、effectCmd、session 选择、事件订阅,以及本地与远程如何复用统一 SDK/server 边界。
  • 教程版本:v1.18.16
  • 源码基线:a3647eb025c7615159d417dcc49fc39fdaeba65b
  • 章节元数据:/versions/v1-18-16/data/chapters.json
  • 源码映射:/versions/v1-18-16/data/source-map.json
  • packages/opencode/package.json
  • packages/opencode/src/index.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/cli/effect-cmd.ts

本章以 OpenCode 源码版本 v1.18.16(提交 a3647eb025c7)为证据基线。我们追踪一个典型场景:用户在项目目录执行 opencode run "解释这个项目" --file package.json,输入怎样越过 CLI,进入统一的 session API?这是由源码拼出的典型路径,不是一次真实运行录屏。

学完这一章,你应该能够:

  1. 画出 bin -> index.ts -> RunCommand -> SDK -> session.prompt 的启动地图。
  2. 区分 CLI 入口、项目 instance、session runtime 和输出渲染各自的职责。
  3. 沿源码解释 message、stdin、--file 与 session 选择怎样组成一次请求。
  4. 说明本地模式为什么也通过 SDK/API,而不是绕过 server handler。
  5. 判断远程 --attach、权限请求和 session 结束时分别走哪条分支。
  6. 把“薄入口、统一内核”的方法迁移到自己的 mini agent。

OpenCode CLI 是一个输入与运行方式适配器:它解析命令行、确定项目和 session、订阅事件,再把标准化的 parts 通过 SDK 交给同一套 session runtime。

本章只回答一个问题:

从敲下 opencode run ... 到 agent 开始工作,中间到底是谁在搬运和转换输入?

答案不是“CLI 直接调用模型”。CLI 的终点是 client.session.prompt(...);模型、工具与循环都在更深的 session 层。证据见 packages/opencode/src/cli/cmd/run.ts

  • 根入口依然保持“只注册命令和全局 middleware”的薄结构,但现在显式注册了 attachtuiplugdb 等更多产品入口。
  • run 的交互执行已收敛到 run/runtime.ts:已有 SDK client 走 runInteractiveMode,本地 in-process fetch 走 runInteractiveLocalMode
  • 非交互路径仍把文本与附件组成 parts 后调用 client.session.prompt;因此“CLI 适配输入、session 执行 agent”的边界没有改变。
v1.18.16 根命令注册 packages/opencode/src/index.ts:45-103

入口负责选命令,不在这里实现 agent loop。

45const cli = yargs(args)创建 CLI 参数解析器。46  .parserConfiguration({ "populate--": true })47  .scriptName("opencode")48  .wrap(100)49  .help("help", "show help")50  .alias("help", "h")51  .version("version", "show version number", InstallationVersion)52  .alias("version", "v")53  .option("print-logs", {声明一个 CLI 选项。54    describe: "print logs to stderr",55    type: "boolean",56  })57  .option("log-level", {声明一个 CLI 选项。58    describe: "log level",59    type: "string",60    choices: ["DEBUG", "INFO", "WARN", "ERROR"],61  })62  .option("pure", {声明一个 CLI 选项。63    describe: "run without external plugins",64    type: "boolean",65  })66  .middleware(async (opts) => {执行前先处理中间件。67    if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"按条件进入分支。68    if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel按条件进入分支。69    if (opts.pure) {按条件进入分支。70      process.env.OPENCODE_PURE = "1"71    }7273    Heap.start()7475    process.env.AGENT = "1"76    process.env.OPENCODE = "1"77    process.env.OPENCODE_PID = String(process.pid)78  })79  .usage("")80  .completion("completion", "generate shell completion script")处理命令执行。81  .command(AcpCommand)注册 CLI 子命令。82  .command(McpCommand)注册 CLI 子命令。83  .command(TuiThreadCommand)注册 CLI 子命令。84  .command(AttachCommand)注册 CLI 子命令。85  .command(RunCommand)注册 CLI 子命令。86  .command(GenerateCommand)注册 CLI 子命令。87  .command(DebugCommand)注册 CLI 子命令。88  .command(ConsoleCommand)注册 CLI 子命令。89  .command(ProvidersCommand)注册 CLI 子命令。90  .command(AgentCommand)注册 CLI 子命令。91  .command(UpgradeCommand)注册 CLI 子命令。92  .command(UninstallCommand)注册 CLI 子命令。93  .command(ServeCommand)注册 CLI 子命令。94  .command(WebCommand)注册 CLI 子命令。95  .command(ModelsCommand)注册 CLI 子命令。96  .command(StatsCommand)注册 CLI 子命令。97  .command(ExportCommand)注册 CLI 子命令。98  .command(ImportCommand)注册 CLI 子命令。99  .command(GithubCommand)注册 CLI 子命令。100  .command(PrCommand)注册 CLI 子命令。101  .command(SessionCommand)注册 CLI 子命令。102  .command(PluginCommand)注册 CLI 子命令。103  .command(DbCommand)注册 CLI 子命令。
非交互与交互 run 的分流 packages/opencode/src/cli/cmd/run.ts:858-928

同一命令在 session.prompt、远程交互和本地 in-process runtime 之间选择。

858          const model = pick(args.model)859          const result = await client.session.prompt({把输入交给会话主流程。860            sessionID,861            agent,862            model,863            variant: args.variant,864            parts: [...files, { type: "text", text: message }],865          })866          if (result.error) {按条件进入分支。867            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。868            process.exitCode = 1869            return返回给上一层。870          }871          await finish()872          return返回给上一层。873        }874875        const model = pick(args.model)876        const { runInteractiveMode } = await import("./run/runtime")进入 CLI 主执行路径。877        try {开始保护性执行。878          await runInteractiveMode({进入 CLI 主执行路径。879            sdk: client,880            directory: cwd,881            sessionID,882            sessionTitle: sess.title,883            resume: Boolean(args.session || args.continue) && !args.fork,884            replay,885            replayLimit: args["replay-limit"],886            agent,887            model,888            variant: args.variant,889            files,890            initialInput,891            createSession: createFreshSession,892            thinking,893            backgroundSubagents: flags.experimentalBackgroundSubagents,894            demo: args.demo,895          })896        } catch (error) {897          dieInteractive(error)898        }899        return返回给上一层。900      }901902      if (interactive && !args.attach && !args.session && !args.continue) {按条件进入分支。903        const model = pick(args.model)904        const { runInteractiveLocalMode } = await import("./run/runtime")按需加载模块。905        const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {906          const { Server } = await import("@/server/server")按需加载模块。907          const request = new Request(input, init)908          const headers = new Headers(request.headers)909          const auth = ServerAuth.header()910          if (auth) headers.set("Authorization", auth)按条件进入分支。911          return Server.Default().app.fetch(new Request(request, { headers }))复用后端请求入口。912        }) as typeof globalThis.fetch913914        try {开始保护性执行。915          return await runInteractiveLocalMode({返回给上一层。916            directory: directory ?? root,917            fetch: fetchFn,918            resolveAgent: localAgent,919            session,920            share,921            createSession: createFreshSession,922            agent: args.agent,923            model,924            variant: args.variant,925            replay,926            replayLimit: args["replay-limit"],927            files,928            initialInput,

2. 为什么先读 CLI,而不是先读模型

Section titled “2. 为什么先读 CLI,而不是先读模型”

学习 Agent 项目时,最容易从“模型 API 在哪里”开始搜索。这样会漏掉三个影响真实行为的问题:

  • 当前工作目录是谁决定的?
  • 新建、继续和 fork 会话的规则在哪里?
  • 文本、管道输入和附件怎样变成同一次 prompt?

这些都发生在模型调用之前。CLI 像服务台:它不处理工单本身,但它决定工单属于哪个项目、哪份档案,以及带了哪些附件。

3. 先画地图:入口到 runtime 的边界

Section titled “3. 先画地图:入口到 runtime 的边界”
操作系统启动 opencode
|
v
packages/opencode/bin/opencode
|
v
src/index.ts 根命令、全局初始化、错误出口
|
v
effectCmd(RunCommand) 加载并最终释放项目 instance
|
v
run.ts 输入、目录、session、事件订阅
|
+---- --attach -------> 远程 HTTP server
|
+---- 本地 -----------> Server.Default().app.fetch
|
v
createOpencodeClient
|
v
client.session.prompt
负责什么不负责什么
index.ts根参数、全局中间件、子命令注册、顶层错误处理不理解 prompt 内容
effectCmd为需要项目状态的命令提供 InstanceRef 并清理不决定 session
RunCommand整理输入、选择 session、连接 server、渲染事件不实现 agent loop
SDK/server统一 API 边界不解析 shell argv
session runtime保存消息并运行 agent不关心输入来自 CLI 还是别的客户端

通用命令行程序也需要前三层;OpenCode 特有的是 session、权限事件、attach 与 in-process server 等产品能力。

1async function runCommand(argv: string[]) {定义一段可复用逻辑。2  const input = parse(argv)3  const project = await openProject(input.directory)4  try {开始保护性执行。5    const client = connectOrCreateLocalClient(input.attach)6    const sessionID = await selectOrCreateSession(client, input)7    render(client.subscribe(sessionID))订阅运行时事件。8    await client.session.prompt({把输入交给会话主流程。9      sessionID,10      parts: [...resolveFiles(input.files), textPart(input.message)],11    })12  } finally {13    await project?.dispose()14  }15}

这不是源码逐行翻译,而是从 index.tseffect-cmd.tsrun.ts 抽出的教学骨架。真实实现还要处理 command 模式、交互模式、认证、分享、JSON 输出和权限响应。

5.1 command 注册不等于 command 执行

Section titled “5.1 command 注册不等于 command 执行”

index.ts 用 yargs 注册 RunCommandServeCommand 等命令;真正的 run handler 在 run.ts。可以把它类比为 Picocli 的根命令与子命令 handler,但 OpenCode 后面用 Effect 组合依赖和资源生命周期。来源:packages/opencode/src/index.tspackages/opencode/src/index.ts

  • instance 绑定一个项目目录,承载配置、插件、LSP 等项目级服务;
  • session 是一次可继续、可 fork 的对话/任务档案。

effectCmd 管 instance 生命周期;run.ts 通过 SDK 选择或创建 session。把两者混成“上下文”会看不清资源何时释放、会话为何还能恢复。

CLI 的 loop(...) 只是消费 server 发出的事件并显示文本、reasoning、工具结果、错误与权限请求。真正决定 agent 是否继续的是 session runtime。来源:packages/opencode/src/cli/cmd/run.ts

6. 追一条典型源码旅程:带 package.json 运行

Section titled “6. 追一条典型源码旅程:带 package.json 运行”

假设用户执行:

1opencode run "解释这个项目" --file package.json

下面只追非交互、本地模式的典型路径。

6.1 第一站:包入口把 opencode 交给启动脚本

Section titled “6.1 第一站:包入口把 opencode 交给启动脚本”

package.jsonbin 字段把命令名映射到 ./bin/opencode

1"bin": {2  "opencode": "./bin/opencode"3}

来源:packages/opencode/package.json

这只证明操作系统入口指向启动脚本;本章不展开该脚本的平台分发细节。源码主入口随后落在 src/index.ts

6.2 第二站:index.ts 建根命令并完成全局初始化

Section titled “6.2 第二站:index.ts 建根命令并完成全局初始化”

hideBin(process.argv) 去掉 runtime 与脚本位置,yargs 接管剩余参数。全局 middleware 处理 --pure、日志、Heap 与进程标识,然后命令表注册 RunCommand。来源:packages/opencode/src/index.tspackages/opencode/src/index.ts

为什么把这些放在根命令?因为日志和运行标识是所有子命令的共同横切关注点,不应复制到每个 handler。

6.3 第三站:effectCmd 为 handler 装上项目运行时外壳

Section titled “6.3 第三站:effectCmd 为 handler 装上项目运行时外壳”

RunCommand 声明:本地运行需要 instance,--attach 连接远程 server 时不需要本地 instance。

1instance: (args) => !args.attach,2directory: (args) =>3  args.dir && !args.attach4    ? path.resolve(process.cwd(), args.dir)5    : process.cwd(),

节选自 packages/opencode/src/cli/cmd/run.ts

effectCmd 根据这个决定加载 InstanceStore,向 handler 提供 InstanceRef,并在 finally 中调用 store.dispose(ctx)。来源:packages/opencode/src/cli/effect-cmd.ts

Java 可以把它类比为命令拦截器加 try-with-resources。类比的边界是:Effect 还编码服务依赖与错误通道,不只是一个普通 AOP wrapper。

6.4 第四站:message、stdin 和文件被整理成 parts

Section titled “6.4 第四站:message、stdin 和文件被整理成 parts”

run.ts 先把位置参数与 -- 后的参数拼成 message。若 stdin 不是 TTY,还会读取管道输入;两者同时存在时用换行连接。来源:packages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/cmd/run.ts

--file package.json,CLI 在确定目录后:

  1. 把相对路径解析为绝对路径;
  2. 检查目标存在;
  3. 区分目录与普通文本;
  4. 生成 { type: "file", url, filename, mime }

来源:packages/opencode/src/cli/cmd/run.ts

注意边界:这里没有读取文件正文,只构造 file part。文件怎样成为模型上下文是下一章 SessionPrompt.createUserMessage 的职责。

6.5 第五站:先确定档案,再发送委托

Section titled “6.5 第五站:先确定档案,再发送委托”

session(sdk) 的决策顺序是:

显式 --session -> 读取指定 session -> 可选 fork
否则 --continue -> 找最近的根 session -> 可选 fork
否则 -> 创建新 session,并写入 CLI 权限规则

证据见 packages/opencode/src/cli/cmd/run.ts

session-aware 的意义是:CLI 进程可以退出,但任务档案仍能被继续;fork 则允许从旧历史分出新路线,而不覆盖原会话。

非交互路径先调用 client.event.subscribe(),启动 CLI 渲染循环,然后才调用 client.session.prompt(...)

1const events = await client.event.subscribe()订阅运行时事件。2loop(client, events).catch(/* ... */)34const result = await client.session.prompt({把输入交给会话主流程。5  sessionID,6  agent,7  model,8  variant: args.variant,9  parts: [...files, { type: "text", text: message }],10})

节选自 packages/opencode/src/cli/cmd/run.ts

先订阅可减少漏掉早期事件的风险。之后 agent 输出并不是由 prompt 返回值逐字打印,而是通过 session 事件持续渲染。

若未指定 --attach,CLI 构造一个 in-process fetch

1const fetchFn = async (input, init) => {2  const { Server } = await import("@/server/server")按需加载模块。3  return Server.Default().app.fetch(new Request(input, init))复用后端请求入口。4}56const sdk = createOpencodeClient({创建 SDK 客户端。7  baseUrl: "http://opencode.internal",8  fetch: fetchFn,9  directory,10})

节选自 packages/opencode/src/cli/cmd/run.ts

这里没有网络回环的必然开销,却复用了 SDK 的请求形状和 server handler。--attach 则让同一个 execute(sdk) 使用远程 base URL。来源:packages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/cmd/run.ts

这条边界带来一个重要性质:本地 CLI 与远程客户端不必各实现一套 session 业务。

6.8 第八站:CLI 等到 session idle,而不是猜回答结束

Section titled “6.8 第八站:CLI 等到 session idle,而不是猜回答结束”

事件循环按当前 sessionID 过滤事件:

  • message.updated 显示本轮 agent/model;
  • message.part.updated 显示工具、文本、reasoning 与 step;
  • session.error 记录错误;
  • session.status 变为 idle 时退出;
  • permission.asked 在非交互默认拒绝,除非显式使用危险的跳过权限选项。

来源:packages/opencode/src/cli/cmd/run.ts

这说明 CLI 用 runtime 的状态作为完成信号,而不是依赖“最后一段文本出现了”这种脆弱判断。

若传入 --command,CLI 调用 client.session.command(...);否则才构造 text/file parts 调用 session.prompt(...)。来源:packages/opencode/src/cli/cmd/run.ts。因此不能把所有 opencode run 都描述成 session.prompt

本地 --dir 会改变进程目录;attach 模式下 --dir 直接作为远程 server 的目录字符串传给 SDK。来源:packages/opencode/src/cli/cmd/run.ts。两种模式都叫 directory,但解析权不同。

7.3 非交互模式不能停在无人回答的权限框

Section titled “7.3 非交互模式不能停在无人回答的权限框”

CLI 收到 permission.asked 时默认回复 reject;只有 --dangerously-skip-permissions 才回复 once。来源:packages/opencode/src/cli/cmd/run.ts。这是非交互可终止性的保护,也是危险开关名字如此直白的原因。

8. OpenCode 的选择:替代方案与代价

Section titled “8. OpenCode 的选择:替代方案与代价”
设计问题OpenCode 的选择可选做法收益与代价
本地入口如何调用业务in-process fetch + SDK + server handlerCLI 直接调用内部 service本地/远程路径一致;多一层 API 抽象
项目资源谁清理effectCmd 统一 finally dispose每个命令自行清理生命周期可靠;wrapper 依赖更重
输出怎样到 CLI订阅事件流等 prompt 返回完整结果可展示工具与流式状态;渲染逻辑要处理更多事件
会话怎样选择新建、继续、指定、fork 明确分支每次创建临时上下文可恢复、可分叉;CLI 参数和错误分支更多
权限无人响应怎么办非交互默认拒绝永久等待或默认允许自动化能收口且更安全;部分任务会提前失败

表中的选择由源码直接证明;收益与代价是基于结构的设计解释。

9. TypeScript / Effect:只补会挡路的三点

Section titled “9. TypeScript / Effect:只补会挡路的三点”

这是一个按本次参数计算的资源策略,不是静态配置。函数返回 false 时,handler 不能依赖本地 InstanceRef

1parts: [...files, { type: "text", text: message }]

...files 把所有附件 part 展开,再追加一个 text part。顺序也是请求数据的一部分。

它让 handler 以接近顺序代码的方式组合 Effect,并附加命名 tracing span。可以暂时类比带依赖注入与错误通道的异步 service 方法,但不要把 yield* 理解成普通 generator 在产出数据。

方法一:让入口只做不可避免的适配

Section titled “方法一:让入口只做不可避免的适配”

CLI 解析 argv、stdin、路径和输出格式;业务状态仍从统一 API 进入。

验证问题:增加 Web 或 IDE 客户端时,你是否需要复制 session 业务?

方法二:把资源生命周期放在命令外壳

Section titled “方法二:把资源生命周期放在命令外壳”

需要项目状态的命令统一加载/释放,不需要的命令显式跳过。

验证问题:handler 抛错或被中断时,项目资源仍会清理吗?

CLI 以 session.status: idle 收口,而不是猜测某条输出是否是最终答案。

验证问题:模型只调用工具、没有文本时,你的 CLI 会不会误判已经结束?

不要看上文,补全这段话:

操作系统先从 ______ 找到 opencode 入口。index.ts 负责 ______,effectCmd 负责 ______。RunCommand 把文本、stdin 和文件变成 ______,确定 ______ 后,通过 ______ 调用统一的 session API。CLI 最终根据 ______ 退出。

如果你把“SDK 调用”说成“直接调用模型”,请回看 packages/opencode/src/cli/cmd/run.ts

  1. 入门:从 packages/opencode/src/index.ts 跳到 RunCommand,列出 run 最少需要的三个输入。
  2. 进阶:画出 --session--continue--fork 的决策树。
  3. 辨析:解释 instance 与 session 的生命周期为什么不同。
  4. 失败路径:说明不存在的 --file、不存在的 session、权限请求分别在哪里收口。

写一个 mini-agent run:支持 message、stdin、--file--session,并让本地与远程模式共享同一个 client 接口。验收标准是入口代码不包含任何模型 provider 分支。

12. 最后复盘:门口已经把工单送进去了

Section titled “12. 最后复盘:门口已经把工单送进去了”

本章的主链只有一条:

argv -> yargs -> effectCmd -> RunCommand -> SDK/server -> session.prompt

你现在应该能解释:CLI 决定“从哪里来、属于哪个项目和 session、带什么 parts、怎样展示结果”,但不决定模型怎样推理或工具怎样执行。

下一章的问题因此很自然:session.prompt 收到的 text/file drafts,怎样变成可持久化的 user message 与 parts?为什么文件附件不能只保留一条路径?接下来进入“用户输入与会话”。