# CLI / 启动入口

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

## 0. 本章学习目标

学完这一章，你应该能够：

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。

## 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 相比旧基线改变了什么

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

<!-- source-ref path="packages/opencode/src/index.ts" lines="45-103" title="v1.18.16 根命令注册" note="入口负责选命令，不在这里实现 agent loop。" -->

<!-- source-ref path="packages/opencode/src/cli/cmd/run.ts" lines="858-928" title="非交互与交互 run 的分流" note="同一命令在 session.prompt、远程交互和本地 in-process runtime 之间选择。" -->

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

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

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

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

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

```text
操作系统启动 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 等产品能力。

## 4. 最小机制：先看 11 行骨架

```ts
async function runCommand(argv: string[]) {
  const input = parse(argv)
  const project = await openProject(input.directory)
  try {
    const client = connectOrCreateLocalClient(input.attach)
    const sessionID = await selectOrCreateSession(client, input)
    render(client.subscribe(sessionID))
    await client.session.prompt({
      sessionID,
      parts: [...resolveFiles(input.files), textPart(input.message)],
    })
  } finally {
    await project?.dispose()
  }
}
```

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

## 5. 读源码前，只分清三个概念

### 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

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

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

### 5.3 事件订阅不等于 agent loop

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

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

假设用户执行：

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

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

### 6.1 第一站：包入口把 `opencode` 交给启动脚本

`package.json` 的 `bin` 字段把命令名映射到 `./bin/opencode`：

```json
"bin": {
  "opencode": "./bin/opencode"
}
```

来源：`packages/opencode/package.json`

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

### 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 装上项目运行时外壳

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

```ts
instance: (args) => !args.attach,
directory: (args) =>
  args.dir && !args.attach
    ? path.resolve(process.cwd(), args.dir)
    : 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

`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 在确定目录后：

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

来源：`packages/opencode/src/cli/cmd/run.ts`。

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

### 6.5 第五站：先确定档案，再发送委托

`session(sdk)` 的决策顺序是：

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

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


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

### 6.6 第六站：先订阅，再发 prompt

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

```ts
const events = await client.event.subscribe()
loop(client, events).catch(/* ... */)

const result = await client.session.prompt({
  sessionID,
  agent,
  model,
  variant: args.variant,
  parts: [...files, { type: "text", text: message }],
})
```

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

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

### 6.7 第七站：本地也走 SDK/API

若未指定 `--attach`，CLI 构造一个 in-process `fetch`：

```ts
const fetchFn = async (input, init) => {
  const { Server } = await import("@/server/server")
  return Server.Default().app.fetch(new Request(input, init))
}

const sdk = createOpencodeClient({
  baseUrl: "http://opencode.internal",
  fetch: fetchFn,
  directory,
})
```

节选自 `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，而不是猜回答结束

事件循环按当前 `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. 三条容易走错的分支

### 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` 的目录属于远程语义

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

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

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

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

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

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

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

### 9.1 `instance: (args) => !args.attach`

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

### 9.2 object spread 与 parts

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

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

### 9.3 `Effect.fn(...)(function* () {})`

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

## 10. 可以带走的方法

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

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

验证问题：增加 Web 或 IDE 客户端时，你是否需要复制 session 业务？

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

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

验证问题：handler 抛错或被中断时，项目资源仍会清理吗？

### 方法三：用领域状态结束命令

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

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

## 11. 费曼复述与练习阶梯

### 11.1 60 秒复述

不要看上文，补全这段话：

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

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

### 11.2 读源码练习

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

### 11.3 小实现

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

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

本章的主链只有一条：

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

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

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