CLI / 启动入口
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 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
主要源码路径
Section titled “主要源码路径”packages/opencode/package.jsonpackages/opencode/src/index.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/effect-cmd.ts
本章以 OpenCode 源码版本
v1.18.16(提交a3647eb025c7)为证据基线。我们追踪一个典型场景:用户在项目目录执行opencode run "解释这个项目" --file package.json,输入怎样越过 CLI,进入统一的 session API?这是由源码拼出的典型路径,不是一次真实运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
bin -> index.ts -> RunCommand -> SDK -> session.prompt的启动地图。 - 区分 CLI 入口、项目 instance、session runtime 和输出渲染各自的职责。
- 沿源码解释 message、stdin、
--file与 session 选择怎样组成一次请求。 - 说明本地模式为什么也通过 SDK/API,而不是绕过 server handler。
- 判断远程
--attach、权限请求和 session 结束时分别走哪条分支。 - 把“薄入口、统一内核”的方法迁移到自己的 mini agent。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode CLI 是一个输入与运行方式适配器:它解析命令行、确定项目和 session、订阅事件,再把标准化的 parts 通过 SDK 交给同一套 session runtime。
本章只回答一个问题:
从敲下
opencode run ...到 agent 开始工作,中间到底是谁在搬运和转换输入?
答案不是“CLI 直接调用模型”。CLI 的终点是 client.session.prompt(...);模型、工具与循环都在更深的 session 层。证据见 packages/opencode/src/cli/cmd/run.ts。
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- 根入口依然保持“只注册命令和全局 middleware”的薄结构,但现在显式注册了
attach、tui、plug、db等更多产品入口。 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 | vpackages/opencode/bin/opencode | vsrc/index.ts 根命令、全局初始化、错误出口 | veffectCmd(RunCommand) 加载并最终释放项目 instance | vrun.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 等产品能力。
4. 最小机制:先看 11 行骨架
Section titled “4. 最小机制:先看 11 行骨架”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.ts、effect-cmd.ts 和 run.ts 抽出的教学骨架。真实实现还要处理 command 模式、交互模式、认证、分享、JSON 输出和权限响应。
5. 读源码前,只分清三个概念
Section titled “5. 读源码前,只分清三个概念”5.1 command 注册不等于 command 执行
Section titled “5.1 command 注册不等于 command 执行”index.ts 用 yargs 注册 RunCommand、ServeCommand 等命令;真正的 run handler 在 run.ts。可以把它类比为 Picocli 的根命令与子命令 handler,但 OpenCode 后面用 Effect 组合依赖和资源生命周期。来源:packages/opencode/src/index.ts、packages/opencode/src/index.ts。
5.2 project instance 不等于 session
Section titled “5.2 project instance 不等于 session”- instance 绑定一个项目目录,承载配置、插件、LSP 等项目级服务;
- session 是一次可继续、可 fork 的对话/任务档案。
effectCmd 管 instance 生命周期;run.ts 通过 SDK 选择或创建 session。把两者混成“上下文”会看不清资源何时释放、会话为何还能恢复。
5.3 事件订阅不等于 agent loop
Section titled “5.3 事件订阅不等于 agent loop”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.json 的 bin 字段把命令名映射到 ./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.ts、packages/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.ts、packages/opencode/src/cli/cmd/run.ts、packages/opencode/src/cli/cmd/run.ts。
对 --file package.json,CLI 在确定目录后:
- 把相对路径解析为绝对路径;
- 检查目标存在;
- 区分目录与普通文本;
- 生成
{ 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 则允许从旧历史分出新路线,而不覆盖原会话。
6.6 第六站:先订阅,再发 prompt
Section titled “6.6 第六站:先订阅,再发 prompt”非交互路径先调用 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 事件持续渲染。
6.7 第七站:本地也走 SDK/API
Section titled “6.7 第七站:本地也走 SDK/API”若未指定 --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.ts、packages/opencode/src/cli/cmd/run.ts、packages/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 的状态作为完成信号,而不是依赖“最后一段文本出现了”这种脆弱判断。
7. 三条容易走错的分支
Section titled “7. 三条容易走错的分支”7.1 --command 不走普通 prompt payload
Section titled “7.1 --command 不走普通 prompt payload”若传入 --command,CLI 调用 client.session.command(...);否则才构造 text/file parts 调用 session.prompt(...)。来源:packages/opencode/src/cli/cmd/run.ts。因此不能把所有 opencode run 都描述成 session.prompt。
7.2 --attach 的目录属于远程语义
Section titled “7.2 --attach 的目录属于远程语义”本地 --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 handler | CLI 直接调用内部 service | 本地/远程路径一致;多一层 API 抽象 |
| 项目资源谁清理 | effectCmd 统一 finally dispose | 每个命令自行清理 | 生命周期可靠;wrapper 依赖更重 |
| 输出怎样到 CLI | 订阅事件流 | 等 prompt 返回完整结果 | 可展示工具与流式状态;渲染逻辑要处理更多事件 |
| 会话怎样选择 | 新建、继续、指定、fork 明确分支 | 每次创建临时上下文 | 可恢复、可分叉;CLI 参数和错误分支更多 |
| 权限无人响应怎么办 | 非交互默认拒绝 | 永久等待或默认允许 | 自动化能收口且更安全;部分任务会提前失败 |
表中的选择由源码直接证明;收益与代价是基于结构的设计解释。
9. TypeScript / Effect:只补会挡路的三点
Section titled “9. TypeScript / Effect:只补会挡路的三点”9.1 instance: (args) => !args.attach
Section titled “9.1 instance: (args) => !args.attach”这是一个按本次参数计算的资源策略,不是静态配置。函数返回 false 时,handler 不能依赖本地 InstanceRef。
9.2 object spread 与 parts
Section titled “9.2 object spread 与 parts”1parts: [...files, { type: "text", text: message }]
...files 把所有附件 part 展开,再追加一个 text part。顺序也是请求数据的一部分。
9.3 Effect.fn(...)(function* () {})
Section titled “9.3 Effect.fn(...)(function* () {})”它让 handler 以接近顺序代码的方式组合 Effect,并附加命名 tracing span。可以暂时类比带依赖注入与错误通道的异步 service 方法,但不要把 yield* 理解成普通 generator 在产出数据。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:让入口只做不可避免的适配
Section titled “方法一:让入口只做不可避免的适配”CLI 解析 argv、stdin、路径和输出格式;业务状态仍从统一 API 进入。
验证问题:增加 Web 或 IDE 客户端时,你是否需要复制 session 业务?
方法二:把资源生命周期放在命令外壳
Section titled “方法二:把资源生命周期放在命令外壳”需要项目状态的命令统一加载/释放,不需要的命令显式跳过。
验证问题:handler 抛错或被中断时,项目资源仍会清理吗?
方法三:用领域状态结束命令
Section titled “方法三:用领域状态结束命令”CLI 以 session.status: idle 收口,而不是猜测某条输出是否是最终答案。
验证问题:模型只调用工具、没有文本时,你的 CLI 会不会误判已经结束?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,补全这段话:
操作系统先从 ______ 找到
opencode入口。index.ts负责 ______,effectCmd负责 ______。RunCommand把文本、stdin 和文件变成 ______,确定 ______ 后,通过 ______ 调用统一的 session API。CLI 最终根据 ______ 退出。
如果你把“SDK 调用”说成“直接调用模型”,请回看 packages/opencode/src/cli/cmd/run.ts。
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:从
packages/opencode/src/index.ts跳到RunCommand,列出run最少需要的三个输入。 - 进阶:画出
--session、--continue、--fork的决策树。 - 辨析:解释 instance 与 session 的生命周期为什么不同。
- 失败路径:说明不存在的
--file、不存在的 session、权限请求分别在哪里收口。
11.3 小实现
Section titled “11.3 小实现”写一个 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?为什么文件附件不能只保留一条路径?接下来进入“用户输入与会话”。