# 命令行入口、无头模式与打包

> Agent CLI 的外壳：main.ts 怎样守住 stdout 与 stderr、兜住进程错误与退出，全部子命令与全局选项及其限制，-p 无头模式的流程与三种输出格式，Node bundle 与 SEA 两条打包路径，发行包的 --web 分流与安装脚本，以及界面语言检测。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/cli-surface
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`apps/zcode-cli/packages/cli` 是 Agent CLI 的外壳：81 个源文件、约 1.06 万行，另有 2700 行构建脚本。它自己几乎不含业务，做的是三件事：在进程层面把输入输出与退出管好（`main.ts`），把命令行翻译成对 bootstrap 的调用（`run.ts` 与各个 `*-command.ts`），以及把整套东西打成可以分发的产物。会话运行时怎样装配见上一篇[bootstrap：把运行时拼起来](https://daiw.org/manual/zcode/bootstrap-assembly)；TUI 的界面细节归[终端界面](https://daiw.org/manual/zcode/tui)，插件、技能、命令、钩子各有专篇，这里只列命令。

仓库里有三个都叫 `zcode` 的可执行入口，先分清楚：

| 入口 | 产物 | 用途 |
| --- | --- | --- |
| `@zcode/cli` 的 bin | `dist/zcode.cjs`（`apps/zcode-cli/packages/cli/package.json:6`） | Agent CLI 本体，本篇主角；SEA 版把它连同 Node 打成单文件 |
| 发行包的 `bin/zcode.mjs` | `scripts/zcode-distribution/runner.mjs` 复制而来（`scripts/build-zcode.mjs:202`） | 第一个参数是 `--web` 时起 Web，其余交给 Agent CLI |
| `@zcode/server-cli` 的 bin | `dist/server-cli.js`（`packages/zcode-server-cli/package.json:4`） | 常驻 Web 服务的 serve、status、stop、restart、update、uninstall，其余参数转给旁边的 `zcode.cjs`（`packages/zcode-server-cli/src/cli.ts:92`、`505`），见[Web 与服务端](https://daiw.org/manual/zcode/server-web) |

## main.ts：进程边界

```mermaid
flowchart TD
  M1["main.ts：设进程名，清洗环境变量"] --> M2["判断协议或 TUI 调用，装 stdout 与 stderr 边界"]
  M2 --> M3["SEA 下解出原生搜索工具"]
  M3 --> P{"__zcode-plugin-host ？"}
  P -->|"是"| PH["直接跑插件的 MCP 服务器，不加载 run"]
  P -->|"否"| M4["准备 Provider 配置路径，动态导入 run.ts"]
  M4 --> R0{"内部入口或 hooks ？"}
  R0 -->|"__internal-search、__zcode-dwf-child、hooks"| RX["各自解析参数"]
  R0 -->|"否"| R1["抽出 --disallowedTools，严格 parseArgs，互斥校验"]
  R1 --> R2{"-p 或 --target ？"}
  R2 -->|"是"| HP["runPrompt 无头运行"]
  R2 -->|"否"| R3["按子命令分发"]
```

`main.ts` 只有 122 行（`apps/zcode-cli/packages/cli/src/main.ts:16`），顺序很讲究：静态导入的只有几个轻量模块，`run.ts` 以及它背后的 bootstrap、core 都等边界装好之后才动态导入（`main.ts:77`）。

- **进程名**：设为 `zcode-cli`（`apps/zcode-cli/packages/cli/src/process-name.ts:2`）；带 `--prepare-storage` 时跳过，因为那种模式可能跑在宿主的 Worker 里，不能改宿主的进程名（`main.ts:18`）。
- **环境变量清洗**：紧接着剔除用户 shell 注入的 `NODE_ENV`、代理与证书变量，网络变量封存起来只还给后续的工具子进程（`main.ts:20`），规则见[执行边界：子进程、环境与网络](https://daiw.org/manual/zcode/exec-boundary)。
- **判断调用类型**：协议调用与 TUI 调用都复用 `run.ts` 的同一份参数定义来判断，避免把 `-p` 或 `--cwd` 的值误认成命令（`apps/zcode-cli/packages/cli/src/arguments.ts:108`、`apps/zcode-cli/packages/cli/src/tui-stderr.ts:11`）。参数不合法时，只要第一个词是 `app-server` 或 `agent-server` 仍按协议保护 stdout（`arguments.ts:119`）。

接下来装的几道边界（`main.ts:30`）：

```ts
  // app-server/agent-server 的 stdout 是严格的 ZCode Protocol 帧通道，三方 SDK 的
  // console.debug 等普通输出不能直接写入 stdout。必须在加载 run/bootstrap 之前将
  // 进程级 console 统一引导到 stderr，否则任意依赖的一行普通日志都会触发传输层 JSON 解析崩溃。
  // TUI 同样独占 stdout；AI SDK 的首条提示使用 console.info，不能绕过 stderr 捕获。
  const restoreConsole =
    isProtocol || isTui ? installStderrConsoleBoundary(process.stderr) : undefined;
  const runtimeWarnings = interceptKnownRuntimeWarnings(process.stderr);
  const tuiStderr = isTui ? interceptTuiStderr(process.stderr) : undefined;
  const stderr = tuiStderr?.passthrough ?? process.stderr;
  const disposeProcessErrorBoundary = isProtocol
    ? installCliProcessErrorBoundary({
        stderr,
        onFatal: (reason) => {
          if (lifecycle)
            lifecycle.requestShutdown(new Error("Uncaught process error", { cause: reason }));
          else process.exit(1);
        },
      })
    : undefined;
```

| 边界 | 做法 | 适用 |
| --- | --- | --- |
| stdout 帧保护 | 把全局 `console` 换成一个 stdout 与 stderr 都指向 stderr 的 `Console`（`apps/zcode-cli/packages/cli/src/protocol-console.ts:10`） | 协议与 TUI |
| 已知告警过滤 | 吞掉 Node 的 SQLite 实验特性告警、`module.register` 弃用告警、`NODE_TLS_REJECT_UNAUTHORIZED` 告警及随后的 trace 提示，以及两条 AI SDK 告警；空写入照常放行，作为退出前的 flush 屏障（`apps/zcode-cli/packages/cli/src/runtime-warnings.ts:8`、`53`） | 全部 |
| TUI stderr 拦截 | 原始 stderr 写入一个只保留最近 65536 个字符的缓冲，CLI 自己的错误走 `passthrough` 直写（`tui-stderr.ts:3`、`60`）；退出时 `restore()` 不带 `flush`（`main.ts:96`），从代码看，缓冲内容直接丢弃 | TUI |
| 协议 stderr 保护 | 监听真实流的 `error` 与 `close`，出口失效后写入变成空操作，避免 EPIPE 引发异常、异常又写 stderr 的自激循环（`apps/zcode-cli/packages/cli/src/protocol-stderr.ts:1`） | 协议 |
| 进程错误边界 | `uncaughtException` 与 `unhandledRejection` 只报告一次：先写一行带 `[zcode-process-exception] ` 前缀的结构化诊断供宿主转发，再请求生命周期有界关闭（`apps/zcode-cli/packages/cli/src/process-errors.ts:34`、`110`）。名称、消息、栈分别截到 128、4000、16000 字符（`packages/shared/src/process-diagnostic.ts:5`） | 协议 |

协议进程还有一个唯一的退出 owner：`createProtocolProcessLifecycle` 在加载运行时之前就接管 stdin（`apps/zcode-cli/packages/cli/src/protocol-lifecycle.ts:10`）。stdin 读到 EOF 后先留 100 毫秒排空再中止，收到 SIGINT、SIGTERM、SIGHUP 或 IO 出错则立即中止；无论哪种，1500 毫秒的截止时间一到就强制退出，即使初始化的 Promise 永远不 settle（`protocol-lifecycle.ts:4`、`31`）。信号退出码为 130、143、129（`protocol-lifecycle.ts:7`）。

其余几步：SEA 版随后解出原生搜索工具（`main.ts:51`，见下文“两条打包路径”）；`__zcode-plugin-host` 在导入 `run.ts` 之前分流，因为导入 run 会求值 Agent、工具注册表和工作流模块，每个插件 MCP 子进程都背上整套业务依赖（`main.ts:60`）；需要运行时的命令先准备好内置与个人 Provider 配置的路径（`main.ts:66`、`apps/zcode-cli/packages/cli/src/provider-runtime-env.ts:105`）。非协议进程在 `run()` 返回后挂一个 1 秒的退出看门狗：事件循环到点还没排空，就带着原退出码强制退出（`main.ts:103`、`apps/zcode-cli/packages/cli/src/shutdown.ts:5`、`128`）；插件宿主成功返回时不挂，因为它的 stdio 句柄正是服务存活的条件（`main.ts:100`）。构建产物最外层还包了一段横幅代码：参数恰好是 `--licenses` 时，在任何初始化之前打印第三方声明，SEA 版再附上 Node.js 的许可证（`apps/zcode-cli/packages/cli/scripts/build.mjs:228`）。

## run.ts：命令路由

`run()` 先处理四类不走全局解析的入口（`apps/zcode-cli/packages/cli/src/run.ts:286`）：

```ts
export const run = async (ctx: RunContext, deps: RunDependencies = {}): Promise<number> => {
  if (ctx.argv[0] === "__internal-search") {
    return runEmbeddedSearchCli(ctx.argv.slice(1), {
      cwd: (deps.cwd ?? process.cwd)(),
      stderr: ctx.stderr,
      stdin: ctx.stdin,
      stdout: ctx.stdout,
    });
  }

  if (isPluginHostInvocation(ctx.argv)) {
    return await runPluginHostCommand(ctx, ctx.argv.slice(1));
  }

  // 与 plugin host 同理，且必须同样在 parseArgs 之前：SEA 下 dwf 的沙箱子进程是本二进制的
  // 自 re-exec，argv 末位是入口文件路径——交给严格 parseArgs 只会报未知参数。
  if (isDwfChildInvocation(ctx.argv)) {
    return await runDwfChildCommand(ctx, ctx.argv.slice(1));
  }

  if (ctx.argv[0] === "hooks") {
    return await runHooksCommand(ctx, deps, version);
  }
```

全局解析用 Node 的 `parseArgs`，`strict: true`（`arguments.ts:3`、`105`），不认识的选项一律报错。于是凡是参数形态不归 CLI 管的入口，都必须抢在它之前：

- `__internal-search`：CLI 把自己当成 `find` 与 `grep` 用。bootstrap 在设置了 `ZCODE_EMBEDDED_SEARCH_COMMAND` 时，让嵌入式搜索后端调用 `<该命令> __internal-search`（`apps/zcode-cli/packages/bootstrap/src/app/embedded-search-backend.ts:11`），后面跟的是 grep 的原生参数；不支持的子命令退出码为 2（`apps/zcode-cli/packages/cli/src/internal-search/embedded-search-cli.ts:68`）。搜索本身见[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)。
- `__zcode-plugin-host <server-path>`：在 Agent 子进程里托管官方插件的 MCP 服务器（`apps/zcode-cli/packages/contracts/src/plugins/index.ts:9`）。它是恢复 Computer Use broker 凭据的最后一道边界：进程里捕获过凭据时，只有插件 id、成对的凭据与 `node_repl` 宿主标记全部吻合才放行，校验在 import 插件代码之前完成（`apps/zcode-cli/packages/cli/src/plugin-host-command.ts:83`），见[插件与官方市场](https://daiw.org/manual/zcode/plugins)。
- `__zcode-dwf-child <entry>`：动态工作流沙箱子进程。SEA 单文件不解释 Node 旗标，只能让二进制自己 re-exec；入口文件写在 `<cwd>/.zcode/workflow-runs/<runId>.mjs`，不走 argv 是为了避开 Windows 命令行 32767 字符的上限（`apps/zcode-cli/packages/cli/src/dwf-child-command.ts:1`），见[动态工作流（二）](https://daiw.org/manual/zcode/dwf-engine)。
- `hooks trust`：有自己的 `--workspace`、`--hook-digest` 等选项，自带一套 `parseArgs`（`apps/zcode-cli/packages/cli/src/hooks-trust-command.ts:13`）。

之后抽出 `--disallowedTools`，解析其余参数，做完所有校验，再分流到 `-p`、`--target` 或子命令（`run.ts:310` 至 `574`）。解析失败时打印错误和帮助到 stderr，退出码 1（`run.ts:317`）。

| 子命令 | 作用 | 详见 |
| --- | --- | --- |
| （无）或 `tui` | 全屏终端界面，缺省命令（`run.ts:65`） | [终端界面](https://daiw.org/manual/zcode/tui) |
| `app-server`、`agent-server` | ZCode Protocol 的 stdio 服务端，两个名字等价（`run.ts:525`）；只有开发态才读 `.env`，免得打包态的协议子进程在握手前就因环境文件出错退出（`run.ts:243`） | [ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol) |
| `doctor` | 打印运行时与打包信息 | 下文 |
| `login`，可带 `zai` 或 `bigmodel`；`logout` | 浏览器授权登录，缺省 `zai`（`apps/zcode-cli/packages/cli/src/login-command.ts:15`） | [账号、Coding Plan 与闲时计划](https://daiw.org/manual/zcode/accounts-plans) |
| `commands`、`skills` | 子命令 `list`（缺省）与 `inspect <name>`（`apps/zcode-cli/packages/cli/src/commands-command.ts:38`、`apps/zcode-cli/packages/cli/src/skills-command.ts:34`） | [技能与自定义命令](https://daiw.org/manual/zcode/skills-commands) |
| `plugins`，别名 `plugin` | list、install、uninstall、enable、disable、update、validate、marketplace（`apps/zcode-cli/packages/cli/src/plugins-command.ts:41`） | [插件与官方市场](https://daiw.org/manual/zcode/plugins) |
| `hooks trust` | status（缺省）、review、grant、revoke（`hooks-trust-command.ts:48`） | [生命周期 Hooks](https://daiw.org/manual/zcode/hooks) |
| `help`、`version` | 与 `-h`、`-v` 相同（`run.ts:519`） | |

没有位置参数形式的提示词：`zcode "修个 bug"` 会被当成未知命令，报错并打印帮助，退出码 1（`run.ts:570`）。全局选项与各自的限制：

| 选项 | 作用 | 限制 |
| --- | --- | --- |
| `-p, --prompt <text>` | 单次无头运行 | 空文本报错；与 `--target` 互斥（`run.ts:401`） |
| `--mode <mode>` | `build`、`edit`、`plan`、`yolo`，大小写不敏感（`run.ts:133`） | `-p` 缺省 `yolo`（`run.ts:42`）；`--target` 与 TUI 不设缺省，走持久化模式或配置。四种模式的语义见[权限模式与规则](https://daiw.org/manual/zcode/permission) |
| `--disallowedTools`、`--disallowed-tools` | 本次运行从工具面移除整个工具，不改持久化配置 | 在 `parseArgs` 之前单独抽取，可连续跟多个值；逗号或空格分隔，括号内的除外；`web_search` 规范成 `WebSearch`（`arguments.ts:127`、`222`）；`Bash(git *)` 也移除整个 Bash（`apps/zcode-cli/packages/i18n/src/locales/en-US.ts:41`） |
| `--output-format <fmt>` | `text`、`json`、`stream-json` | 其他值直接报错，免得调用方拼错后拿到纯文本却无从察觉（`run.ts:114`） |
| `--json` | 旧开关，输出 JSON 摘要 | 显式 `--output-format` 优先（`apps/zcode-cli/packages/cli/src/prompt-command.ts:44`）；`doctor`、`plugins list` 等也认它（`run.ts:211`） |
| `--attach <path>`，可重复 | 给 `-p` 附本地文件，按扩展名判为图片、视频、PDF 或普通文件（`prompt-command.ts:437`） | 只有 `-p` 使用，`--target` 固定传空列表（`run.ts:506`） |
| `--target <text>`、`--target-replace` | 改写成 `/goal <text>` 或 `/goal replace <text>` 无头运行（`run.ts:175`） | 空文本报错；`--target-replace` 必须配 `--target`（`run.ts:152`）。目标模式见[目标模式](https://daiw.org/manual/zcode/goal-target) |
| `-c, --continue`、`--resume <sessionId>` | 继续当前目录最近的会话，或恢复指定会话 | 二者互斥（`run.ts:368`） |
| `--cwd <path>` | 换工作目录 | 必须是可访问的已有目录（`apps/zcode-cli/packages/cli/src/cwd.ts:11`） |
| `--locale <locale>` | `en-US`、`zh-CN`、`auto` | 其他值报错（`run.ts:127`） |
| `--force-mcs` | 对 Anthropic 系 Provider 强制 mid-conversation system 投影 | 只能配 `-p`、`--target` 或 `tui`（`run.ts:448`） |
| `--browser-use=headless`、`--browser-executable <path>` | CLI 自己拉起 Playwright 管理的无头 Chromium（`apps/zcode-cli/packages/cli/src/headless-browser.ts:9`） | 前者只能配 `-p`、`--target` 或 `tui`（`run.ts:436`）；后者必须配前者（`run.ts:358`）。见[node_repl、Browser Use 与 Computer Use](https://daiw.org/manual/zcode/node-repl-browser) |
| `--surface <surface>` | `terminal` 或 `desktop`；后者让系统提示词多一段桌面上下文（`apps/zcode-cli/packages/core/src/context/builder.ts:131`） | 只能配 `-p`、`--target`、`app-server`、`agent-server`（`run.ts:406`） |
| `--memory-bench` | 开启记忆抽取，并在退出前等它完成 | 只能配 `-p` 且不能有位置参数（`run.ts:428`）；要求项目记忆已启用（`prompt-command.ts:251`） |
| `--no-browser` | `login` 只打印授权地址 | |
| `-f, --force` | 非交互终端里卸载插件 | 目前只有 `plugins uninstall` 读它（`apps/zcode-cli/packages/cli/src/plugins-command.ts:343`） |
| `--prepare-storage`、`--stdio` | 前者让 `app-server` 只准备存储就退出（`run.ts:532`）；后者被接受但 `run.ts` 不读 | 宿主以 `app-server --stdio` 启动 Agent（`scripts/zcode-distribution/runner.mjs:183`） |
| `-a, --all`、`--available`、`--keep-data`、`-s, --scope`、`--sparse` | 插件子命令的标志，在全局注册后透传（`arguments.ts:85`） | |
| `--no-color`、`--verbose` | 关掉 ANSI 颜色；错误时多打原因与调用栈 | |

帮助文本（`en-US.ts:10`）比代码少几样：没有 `hooks` 子命令、`agent-server` 别名、`--output-format`、`--prepare-storage`、`-f` 与插件的几个标志，`commands` 与 `skills` 也只写了 `list`。

## -p：无头模式

`runPrompt`（`prompt-command.ts:61`）的流程：

1. **斜杠命令先分流**（`prompt-command.ts:79`）。`/help`、不带名字的 `/skill`、`/login`、`/logout` 在 CLI 里直接处理；`/skill <name> <task>` 改写成强制加载该技能的提示词。创建 App 之后，`/expert`、`/goal`（`--target` 就是它）以及解析不出的自定义命令交给命令中心；能解析的自定义命令与 `/init` 作为普通提示词提交，由 bootstrap 展开（`prompt-command.ts:255`、`447`）；其余文本原样交给运行时，core 在回合入口还认 `/compact`、`/rewind`、`/fork`（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:105`）。
2. **准备进程级资源**：注册信号处理（`prompt-command.ts:144`），加载 `.env`（从 cwd 向上找第一个 `.env` 文件，不覆盖已有变量，`apps/zcode-cli/packages/cli/src/env.ts:50`、`87`），解析 `-c` 或 `--resume` 得到会话 id，准备遥测，启动进程级 Provider Registry，按需拉起无头浏览器（`prompt-command.ts:152` 至 `211`）。
3. **创建 App**（`prompt-command.ts:212`）：

```ts
    app = await createApp({
      browserControlPort: browserRuntime?.browserControlPort,
      env: appEnv,
      // headless 没有交互审批面，core 因此退到 deny broker，于是 CreateWorkflow 的
      // alwaysAsk gate 在 -p 下**必然被拒**（"No permission client configured"）。
      // 这个最小 broker 只按工具名放行 CreateWorkflow，其余工具委托回同一个 deny
      // broker，语义逐字不变。详见 headless-workflow.ts 的注释。
      permissionBroker: createHeadlessPermissionBroker(),
      providerRegistry: providerRegistryRuntime.runtime.registryService,
      configuredDefaultModelSelection: providerRegistryRuntime.configuredDefaultModelSelection,
      // ...
      resume: sessionId !== undefined,
      runtimeConfig: {
        ...(mode ? { mode } : {}),
        ...(toolDisallowlist ? { toolDisallowlist } : {}),
        ...(forceMcs ? { midConversationSystem: { mode: "force" as const } } : {}),
        memory: { extractionEnabled: options.memoryBench === true },
        modelStreaming: "on",
        presentationSurface,
        workingDirectory,
      },
```

几个后果：无头模式没有审批界面，除了 `CreateWorkflow` 与 `AmendWorkflow`，任何需要审批的工具调用都会被拒，原因是 `No permission client configured for <工具名>`（`apps/zcode-cli/packages/cli/src/headless-workflow.ts:31`、`apps/zcode-cli/packages/core/src/permission/broker.ts:28`），`-p` 缺省用 `yolo`，大多数工具因此走不到审批这一步；`--target` 不带 `--mode` 时用的是持久化模式或配置（缺省 `build`），碰到要审批的工具同样会被拒。自动的记忆抽取默认关闭；也不传标题生成配置，TUI 则会开启（`apps/zcode-cli/packages/cli/src/tui-command-state.ts:35`）。

4. **提交与等待**：装上跨回合的常驻事件订阅作为唯一的事件写者，调用 `app.submitPrompt`，`--attach` 的文件按扩展名推断类型后随提示词一起提交（`prompt-command.ts:290`、`293`）。如果观察到动态工作流活动，就每 100 毫秒轮询运行时的两个忙碌事实，等在飞的工作流和它们触发的通知回合全部结束，并存的后台 Bash 与子 Agent 任务也一起等；不设超时，Ctrl+C 才能打断（`headless-workflow.ts:315`、`327`、`331`）。`--memory-bench` 时再等记忆抽取排空（`prompt-command.ts:322`）。
5. **输出**：`response` 取最后一个非空回合的文本，多于一个回合时另带 `turnResponses` 数组（`prompt-command.ts:330`）。三种格式：

| 格式 | stdout | stderr |
| --- | --- | --- |
| `text`（缺省） | 各回合文本，空行分隔（`prompt-command.ts:416`） | 动态工作流进度，节点迁移每 400 毫秒最多一行，开始、结束与 `log` 不节流（`headless-workflow.ts:163`、`176`）；工作区钩子被跳过时的诊断 |
| `json` | 一个格式化的 JSON 对象：`sessionId`、`traceId`、`turnId`、`response`、`usage`、`eventCount`、`projection`，钩子被跳过时另有 `workspaceHookTrust`（`prompt-command.ts:371`） | 无 |
| `stream-json` | 每个会话事件一行 NDJSON，信封与协议服务端相同；工作流进度单独定型为 `workflow.run.progress`；最后一行 `type: "result"` 是流的终止符（`headless-workflow.ts:248`、`prompt-command.ts:345`） | 无 |

走命令中心的那条路径（`/expert`、`/goal` 与 `--target`）不透出事件：`json` 与 `stream-json` 都只打印一个含 `sessionId`、`traceId`、`response` 的 JSON 对象（`prompt-command.ts:538`）。从代码看，`--target` 配 `--output-format stream-json` 拿到的并不是 NDJSON。命令中心若需要用户在几个目标之间选择，无头模式无法弹出选择器，直接失败并提示改用 `--target-replace`（`prompt-command.ts:526`）。

退出码：成功为 0；参数错误或运行出错为 1，stderr 上是 `Error: <消息>`，已知 traceId 时附在后面，`--verbose` 另打原因与调用栈（`prompt-command.ts:418`）；收到 SIGINT、SIGTERM、SIGHUP 时先中止当前回合，清理最多等 2 秒，再以 130、143、129 退出（`shutdown.ts:3`、`6`、`54`）。正常结束时 App、浏览器、遥测依次关闭，每步最多 6 秒，最后释放 Provider Registry（`shutdown.ts:4`、`prompt-command.ts:131`）。

## doctor

`doctor` 不读配置、不碰网络，只报告运行时与打包假设（`run.ts:188`）：CLI 名与进程名、版本、Node 版本、平台与架构、是否 SEA，以及“缺省产物是 node-bundle、SEA 可选”这两条打包声明（`run.ts:205`）。`--json` 输出完整对象，`--verbose` 多打 `execPath` 与 cwd。版本号是构建时注入的 `__CLI_VERSION__`，取自 `apps/zcode-cli/package.json`，当前为 0.16.9（`build.mjs:9`、`113`、`233`）。

## 两条打包路径

```mermaid
flowchart LR
  SRC["src/main.ts"] -->|"build.mjs"| CJS["dist/zcode.cjs 与 dist/provider"]
  SRC -->|"build.mjs --desktop-agent"| DESK["压缩版 zcode.cjs，给桌面端"]
  CJS -->|"build-sea.mjs"| SEA["zcode-平台-架构 单文件"]
  CJS -->|"build-zcode.mjs"| TAR["发行包 zcode-版本.tar.gz"]
  TAR --> INST["install.sh 装到 ~/.zcode/runtime"]
```

**普通 Node bundle**是缺省路径（`build.mjs:204`）。esbuild 把 `src/main.ts` 打成一个 CJS 文件，`target` 定为 `node22`：桌面用 Electron 内置的 Node 24，远程 SSH 复用已部署的 Node v22.16，同一份产物要在两端都能跑（`build.mjs:257`）。`@zcode/tui`、`playwright-core`、`koffi` 保持外置（`build.mjs:14`）：TUI 在运行时经 Node 原生的动态 import 加载，Playwright 依赖运行时的包资源，koffi 按平台动态加载 `.node` 文件（`build.mjs:236`）。注释给 TUI 的理由是 Ink 7 与 yoga-layout 用了顶层 await，可 TUI 如今依赖的是 `@mbears/opentui-core` 与 `@mbears/opentui-react`（`apps/zcode-cli/packages/tui/package.json:22`），仓库里已找不到 Ink，这句注释过时了。另有一个 Zod 去重插件，保证打进去的 Zod v4 只有 `packages/shared` 钉住的那一个版本、一份拷贝（`build.mjs:16`、`74`、`253`），内置 Provider 配置随产物放进 `dist/provider`（`build.mjs:219`）。`--desktop-agent` 模式压缩代码、保留函数名、不带 sourcemap（`build.mjs:123`），由根目录的 `scripts/build-desktop-agent-cli.mjs` 依序构建各工作区包后调用，产物暂存进桌面端的 `bundled-agents`（`scripts/build-desktop-agent-cli.mjs:94`），见[桌面应用](https://daiw.org/manual/zcode/desktop)。

**SEA 单文件**是可选路径（`apps/zcode-cli/packages/cli/scripts/build-sea.mjs:285`）。它要求先有 `dist/zcode.cjs` 与 `postject`，目标平台是 darwin、linux、win 各自的 arm64 与 x64 共六种（`apps/zcode-cli/packages/cli/scripts/sea-targets.mjs:3`），产物名为 `zcode-<平台>-<架构>`，Windows 的平台段写作 `windows` 并加 `.exe`（`sea-targets.mjs:49`）。每个目标的步骤：

1. 收集资源进 SEA blob：TUI 运行时、官方插件（`node-repl-host` 与 `browser-use`，`apps/zcode-cli/packages/cli/scripts/sea-official-plugin-assets.mjs:20`）、原生搜索工具（macOS 与 Linux 为 bfs、ugrep、rg，Windows 没有 bfs，`scripts/native-search-tools-config.mjs:96`）、`playwright-core` 整包、内置 Provider 配置与 Node 许可证；关掉代码缓存与快照（`build-sea.mjs:172`）。
2. 取目标平台的 Node 二进制：可用 `--node-binary` 指定，否则从 nodejs.org 下载与构建机相同版本的发行包，按 `SHASUMS256.txt` 校验 sha256，缓存命中也要重新校验（`build-sea.mjs:301`、`apps/zcode-cli/packages/cli/scripts/sea-node-download.mjs:106`）。
3. 复制二进制，找到 `NODE_SEA_FUSE` 标记；macOS 先去签名，Windows 先剥掉 Authenticode 签名；用 `postject` 注入 blob；macOS 再做 ad-hoc 签名（`build-sea.mjs:257`）。
4. 宿主平台的产物用临时存储目录跑一次 `--version` 冒烟测试（`build-sea.mjs:231`）。

SEA 运行时要把资源解到磁盘上才能用，原生工具、TUI 与 Playwright 写盘前都比对 sha256：

- 原生搜索工具解到 `<storage>/cache/runtime_tools/<target>/<tool>/<version>-<sha>`，同时校验大小与哈希，然后通过 `ZCODE_BFS_BINARY`、`ZCODE_RG_BINARY`、`ZCODE_UGREP_BINARY` 告诉后续代码；用户已设置这些变量时不覆盖（`apps/zcode-cli/packages/cli/src/sea-runtime-tools.ts:52`、`74`、`96`、`119`）。
- TUI 运行时与 Playwright 解到平台缓存目录，macOS 是 `~/Library/Caches/zcode/sea-assets`，Windows 在 `LOCALAPPDATA` 下，其余取 `XDG_CACHE_HOME` 或 `~/.cache`，再按 CLI 版本、目标与清单哈希分目录（`apps/zcode-cli/packages/cli/src/tui-runtime-loader.ts:46`、`120`、`apps/zcode-cli/packages/cli/src/sea-playwright-runtime.ts:43`）。Playwright 清单里的路径必须落在 `node_modules/playwright-core/` 下（`sea-playwright-runtime.ts:108`）。
- 内置 Provider 配置从 SEA 资源里物化到 `~/.zcode/v2` 下（`provider-runtime-env.ts:137`）。

仓库的 CLI README（`apps/zcode-cli/README.md:7`）仍是早期起步模板的口吻：

> Runtime code has zero production dependencies.

实际上 CLI 依赖 `dotenv`、`playwright-core` 与十个工作区包（`apps/zcode-cli/packages/cli/package.json:26`），README 列出的 `npm run bootstrap`、`npm run start`、`npm test` 在 `apps/zcode-cli/package.json` 里也都没有（`apps/zcode-cli/package.json:7`）。

## 发行包 zcode

`pnpm build:zcode` 运行 `scripts/build-zcode.mjs`（根目录 `package.json:22`），把 Agent、Web 前端与后端组成一个不需要 Electron 的发行包（`README.md:86`）。必须先给下载根地址，`ZCODE_DIST_BASE_URL` 或 `--base-url`（`build-zcode.mjs:235`）；随后构建 `@zcode/cli` 及其依赖、`@zcode/server`、`@zcode/web`（`build-zcode.mjs:142`），再组装目录（`build-zcode.mjs:157`）：

| 路径 | 内容 |
| --- | --- |
| `bin/zcode.mjs` | 分流入口 |
| `agent/zcode.cjs`、`agent/provider/` | Agent CLI 与内置 Provider 配置 |
| `agent/node_modules/` | 六个目标平台合并后的 TUI 运行时，同一路径哈希不同就构建失败（`scripts/zcode-distribution/assets.mjs:32`），以及 `playwright-core` |
| `server/`、`web/` | 后端（入口 `entry-http.js`）与前端静态资源 |
| `node_modules/` | 后端的运行时依赖，如 hono、ws、ssh2、node-pty（`assets.mjs:12`），并补上 node-pty 的预编译产物（`assets.mjs:176`） |
| `package.json` | `name: "zcode-runtime"` 与版本号（`build-zcode.mjs:206`） |

输出目录缺省为 `dist/zcode/`：`releases/<version>/zcode-<version>.tar.gz`、同目录的 `sha256.txt`、顶层的 `latest.json`（字段为 `baseUrl`、`createdAt`、`name`、`sha256`、`tarball`、`version`）与 `install.sh`（`build-zcode.mjs:250`、`273`）。版本缺省取根目录 `package.json:3` 的产品版本 3.14.0（`build-zcode.mjs:243`）。

分流逻辑在 `runner.mjs` 末尾（`runner.mjs:252`）：

```js
try {
  const argv = process.argv.slice(2);
  if (argv.length === 1 && ["--version", "-v"].includes(argv[0])) {
    console.log(version);
  } else if (argv[0] === "--web") {
    const options = parseArgs(argv.slice(1));
    if (options.command === "help") console.log(usage());
    else if (options.command === "version") console.log(version);
    else await serve(options);
  } else {
    if (argv.length === 1 && ["--help", "-h"].includes(argv[0])) {
      console.log("Web mode: zcode --web [options] (zcode --web --help for details)\n");
    }
    // CLI 自启动子进程依赖 argv[1]；统一指向真正的 Agent 入口，保留 TTY 与所有原始参数。
    process.argv[1] = agentEntry;
    await import(pathToFileURL(agentEntry).href);
  }
```

非 `--web` 的参数不起子进程，直接在同一进程里 import Agent 入口，TTY 原样保留；`argv[1]` 改成 Agent 入口，是因为 CLI 自己 re-exec 子进程时依赖它。单独一个 `-v` 由分流器回答，打印的是发行包版本（缺省 3.14.0），而 `zcode version` 与 `zcode doctor` 进到 Agent，报的是 CLI 版本 0.16.9。

`--web` 模式（`runner.mjs:170`）：监听地址缺省 `127.0.0.1`（`runner.mjs:37`），端口缺省由系统挑一个空闲的（`runner.mjs:172`）；监听非本机地址时自动生成 24 字节随机令牌，`--token` 指定、`--no-token` 关闭（`runner.mjs:109`、`173`）；本机地址缺省打开浏览器（`runner.mjs:175`）。它用当前 Node 起 `server/entry-http.js`，通过环境变量告诉后端静态资源目录、工作区与令牌，并让后端用 `node agent/zcode.cjs app-server --stdio` 拉起 Agent（`runner.mjs:178`）。Ctrl+C 给子进程发 SIGTERM，1.5 秒后退出（`runner.mjs:226`）。后端怎样托管 Agent，见[Web 与服务端](https://daiw.org/manual/zcode/server-web)。

`install.sh` 是一段 POSIX sh（`scripts/zcode-distribution/installer.mjs:3`）：要求 `node`、`curl`、`tar` 三个命令存在（`installer.mjs:18`），读 `latest.json` 取版本与包名，下载解压到 `~/.zcode/runtime/releases/<version>`，把 `current` 软链接指过去，再在 `~/.local/bin` 写一个 `exec node .../bin/zcode.mjs` 的包装脚本；三个位置可用 `ZCODE_DIST_BASE_URL`、`ZCODE_DIST_HOME`、`ZCODE_DIST_BIN_DIR` 覆盖（`installer.mjs:7`、`45`）。README 说运行发行包需要的 Node 版本以 `mise.toml` 为准（`README.md:162`），安装脚本却只检查 `node` 是否存在；`latest.json` 与 `sha256.txt` 都带了摘要，安装脚本也没有校验下载的包。

## 界面语言

文案集中在 `@zcode/i18n`：`en-US` 与 `zh-CN` 两份目录，内容只有 CLI 帮助、一条语言错误和 TUI 文案（`apps/zcode-cli/packages/i18n/src/types.ts:5`、`apps/zcode-cli/packages/i18n/src/index.ts:25`），其余命令行报错都是英文。语言检测（`apps/zcode-cli/packages/i18n/src/locale.ts:32`）依次看 `LC_ALL`、`LC_MESSAGES`、`LANG`、`LANGUAGE`（后者可以是冒号分隔的列表），再看 `Intl` 给出的区域；去掉 `.UTF-8` 这类编码与 `@` 修饰，下划线换成连字符，`C` 与 `POSIX` 忽略，`en` 开头归为 `en-US`，`zh` 开头归为 `zh-CN`（`locale.ts:5`、`40`）。

检测结果只在请求的语言是 `auto` 时才用上（`locale.ts:24`）。而帮助文本只看 `--locale`（`apps/zcode-cli/packages/cli/src/help.ts:3`），配置里的 `ui.locale` 缺省又是 `en-US`（`apps/zcode-cli/packages/contracts/src/config/index.ts:356`），所以不加 `--locale` 时 `zcode --help` 总是英文，TUI 也要启动时加 `--locale`，或配置里写了 `auto`、`zh-CN` 才显示中文；TUI 里切换语言会写回配置文件（`apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:547`）。TUI 启动时自己合并一遍配置来决定语言，因为需要登录的界面在 App 创建之前就要画出来（`apps/zcode-cli/packages/cli/src/tui-startup-locale.ts:22`）。两份目录也有小出入：`/login` 一行，英文写的是选择 Z.AI 或 BigModel 登录，中文仍是“使用 Z.AI OAuth 登录”（`en-US.ts:56`、`apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:56`）。

下一篇：[终端界面](https://daiw.org/manual/zcode/tui)——不带参数运行 `zcode` 时看到的全屏界面：它的渲染栈、组件与快捷键，以及它为什么不持有任何业务状态。
