终端编码 Agent opencode 命令行:交互模式与脚本化执行
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
opencode 的交互模式和 opencode run 不是同一个东西换了层皮,而是权限行为和输出去向都不一样的两条路径——最要命的一条差异是:非交互执行时没有人能替你按下”同意”,所以默认动作是自动拒绝,不是自动放行。 你如果按”TUI 里能干的事,加个 run 就能在脚本里干”这个心智模型去接 CI,第一次跑就会看到一堆权限被拒的提示,然后 Agent 交出一个半成品。
opencode 是一个小写起首的开源项目名(仓库 https://github.com/anomalyco/opencode ,MIT 许可证,LICENSE 里署名 Copyright 2025 opencode),跑在终端里,能读写你的代码、执行 shell 命令、把文件内容发给模型服务商。下面这些判断全部来自仓库里的 packages/web/src/content/docs/cli.mdx、packages/opencode/src/cli/ui.ts、packages/opencode/src/cli/cmd/run.ts 与 packages/web/src/content/docs/commands.mdx,你可以逐条回去核。
这篇只谈命令行这一层的机制与取舍。如果你要的是 Claude Code 的日常用法,看 Claude Code 教程;要的是 GitHub Copilot 命令行的上手路径,看 Copilot CLI 教程;工具调用层的通用排错思路在 Agent 工具调错排查——本篇不重复那些,只把 opencode 这一个项目的 CLI 行为讲透。
一、两个入口:不带参数是 TUI,带上 run 就是一次性执行
cli.mdx 开篇写得很直白:不带任何参数运行时默认启动 TUI,也就是 opencode;opencode [project] 可以指定一个项目目录。要程序化地跟它打交道,就走 opencode run [message..]。
两条路径的参数表有交集也有分歧。交集部分是会话与模型相关的:--continue(短选项 -c)继续上一个会话,--session(-s)指定会话 ID,--fork 在继续时先把会话分叉出来,--model(-m)用 provider/model 形式指定模型,--agent 指定 agent,--auto 自动批准那些没有被明确拒绝的权限请求。
分歧在于两边各自独有的那些。TUI 那边有 --prompt,还有一串服务端参数(--port、--hostname、--mdns、--mdns-domain、--cors)。run 这边则多了真正为脚本准备的东西:--format(取值 default 或 json)、--file(-f,给消息附文件,可以给多个)、--title、--attach、--command、--variant、--thinking、--share。
消息参数的处理方式值得单说。run.ts 里把位置参数和 -- 之后的参数拼在一起,含空格的片段会被重新加上引号再合并,所以文档里那个不加引号的写法是成立的:
opencode run Explain the use of context in Go
另一条输入通道是标准输入。run.ts 里判断 process.stdin.isTTY,只要 stdin 不是终端就会把管道内容整个读进来,再和命令行给的消息用换行拼接(命令行消息在前,管道内容在后)。也就是说 cat error.log | opencode run 帮我看看这段日志 是被设计支持的用法,不是碰巧能跑。消息和管道都为空、又没给 --command 时,它会直接报错退出,退出码 1。
二、输出到底去了哪:stdout 与 stderr 的分工
这是决定你能不能把它塞进流水线的关键,也是最容易被忽略的一处。
packages/opencode/src/cli/ui.ts 只有一百多行,但把规矩定死了。print 和 println 都写向 process.stderr:
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
}
这个文件里的 error() 会给消息加上 Error: 前缀,logo() 在 stdout 和 stderr 都不是 TTY 时退化成纯字符版的 wordmark,Style 里那一堆是 ANSI 转义序列常量。整套终端装饰——logo、工具调用的行内提示、警告、错误——统统在 stderr。
而模型给出的正文走的是另一条路。run.ts 的事件循环里,文本片段结束时有这么个分支:非 TTY 的 stdout 直接 process.stdout.write(text + EOL),TTY 的 stdout 则回落到 UI.println,也就是回到 stderr。
翻译成你实际会遇到的现象:坐在终端里看的时候,正文其实是从 stderr 出来的;一旦你把 stdout 重定向到文件或者管道给下一个程序,正文会自动切到 stdout,而所有装饰仍然留在 stderr。所以 opencode run "..." > answer.txt 拿到的是干净的回答,不掺 logo 和工具日志。这个设计不需要你加任何”安静模式”参数。
要机器解析就上 --format json。run.ts 里的 emit 函数负责这件事:
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(
JSON.stringify({
type,
timestamp: Date.now(),
sessionID,
...data,
}) + EOL,
)
return true
}
return false
}
每行一个 JSON 对象,公共字段是 type、timestamp、sessionID。事件循环里调用 emit 的地方决定了 type 的取值范围:tool_use、step_start、step_finish、text、reasoning、error。注意 emit 返回 true 时后面的分支会 continue 跳过——JSON 模式下不会再额外走一遍人类可读的渲染,两种格式互斥。
reasoning 事件还多一道闸:非交互模式下 --thinking 默认是关的,不打开就不会有推理块输出。
日志是第三条流。全局参数 --print-logs 把日志打到 stderr,--log-level 接 DEBUG / INFO / WARN / ERROR。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| run 命令定义与事件循环 | 解析参数、建会话、订阅事件流、把事件映射成输出、决定退出码 | packages/opencode/src/cli/cmd/run.ts | 排查退出码、想知道 --format json 到底吐哪些事件 |
| 终端输出原语 | print / println / error / logo 与 ANSI 样式常量,全部写 stderr | packages/opencode/src/cli/ui.ts | 把 stdout 重定向后发现提示语还在屏幕上 |
| CLI 参数与环境变量总表 | 各子命令的 flag、全局 flag、OPENCODE_* 环境变量清单 | packages/web/src/content/docs/cli.mdx | 拼命令行、找某个开关叫什么 |
| 自定义命令规范 | /xxx 命令的 frontmatter、$ARGUMENTS、shell 注入、文件引用 | packages/web/src/content/docs/commands.mdx | 想把一段反复用的提示词固化下来 |
| 权限动作定义 | allow / ask / deny 三态与 --auto 的作用边界 | packages/web/src/content/docs/permissions.mdx | 决定无人值守时开不开自动批准 |
| 交互运行时 | --mini 直连交互模式的实现入口 | packages/opencode/src/cli/cmd/run/runtime.ts | 极少;它硬性要求 stdout 是 TTY |
三、权限:非交互执行的默认动作是拒绝
run.ts 在非交互路径上会先塞进一组规则:
const rules: PermissionV1.Ruleset = interactive
? []
: [
{ permission: "question", action: "deny", pattern: "*" },
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "plan_exit", action: "deny", pattern: "*" },
]
含义很清楚:一次性执行时,Agent 不允许反问你问题,也不允许进出计划态。这条规则跟着 session.create 一起提交,是会话级的。
真正会影响产出质量的是事件循环里对 permission.asked 的处理。带了 --auto 就自动回一个 once;没带的话,先在 stderr 打一行 permission requested: ...; auto-rejecting,然后回一个 reject。
这意味着:一个需要写文件、需要跑测试命令的任务,在没有配置好权限规则、又没开 --auto 的情况下丢进 opencode run,它不会卡住等你,而是一路被拒到底,最后给你一段”我本来想改但没权限”的文字。这不是 bug,是没人值守时唯一安全的默认值,但你得知道它在那儿。
--auto 这个开关在源码里的描述带着一个感叹号:auto-approve permissions that are not explicitly denied (dangerous!)。同一段参数定义里还有两个隐藏别名 --yolo 和 --dangerously-skip-permissions,三个开关在代码里是或的关系,效果完全一样。命名本身就是态度。
permissions.mdx 补了边界:配置里显式写成 "deny" 的规则,--auto 也推不翻;auto 模式只影响那些本来会弹出询问的请求。所以正确姿势不是”开了 auto 就万事大吉”,而是先在配置里把危险动作明确 deny 掉,再用 auto 放行剩下的。文档里给的粒度写法是给 bash 配对象,按命令前缀分别设 allow / ask / deny——git * 放行、rm * 拒绝这一类。
放宽到什么程度合适,是个需要单独想清楚的问题,Agent 权限给太大会怎样 那篇讲了权限边界失守之后典型的塌方路径,这里只强调三件跟 opencode 直接相关的事实:它会在你的机器上执行 shell 命令,会直接改写工作区里的文件,会把读到的文件内容发给你配置的模型服务商。第三条尤其容易被忽略——.env、私钥、客户数据只要被 Agent 读进上下文就出了本机。各家服务商对数据留存和训练使用的规则不同而且会调整,以官方最新说明为准;但你能控制的那一半,是别让它有机会读到不该读的目录。
凭据本身也在本地留了痕。cli.mdx 写明 opencode auth login 配置的 API key 存在 ~/.local/share/opencode/auth.json,启动时从这个文件加载,同时也会读环境变量和项目里的 .env。这个文件的权限和备份策略,属于你自己的责任范围。
四、会话接续、自定义命令,与该不该常驻一个 serve
一次性执行不等于每次从零开始。--continue 会去会话列表里找第一个没有 parentID 的会话接着聊;--session 直接指定 ID,找不到就报 Session not found 并以 1 退出。--fork 是在这两者基础上先分叉一份再继续,源码里有明确校验:单独用 --fork 而不给 --continue 或 --session,直接退出。
--title 有个小设计:给了值就用你的值,给了空值则截取消息前 50 个字符,超长补省略号。会话本身可以用 opencode session list(支持 -n 限制条数、--format 选 table 或 json)和 opencode session delete <sessionID> 管理,还能 opencode export [sessionID] 导出 JSON、opencode import <file> 导回来。export 带 --sanitize 可以脱敏。用量方面有 opencode stats,支持按天数、按项目筛。
--command 是另一条脚本化通道:不发自由文本,而是执行一个自定义的斜杠命令,message 位置的内容变成传给它的参数。命令怎么定义在 commands.mdx 里:可以是配置文件里的 command 字段(template 必填,description / agent / model / subtask 可选),也可以是放在 ~/.config/opencode/commands/ 或项目里 .opencode/commands/ 的 markdown 文件,文件名即命令名。
模板里能用的东西不少:$ARGUMENTS 拿全部参数,$1、$2、$3 拿位置参数,@ 加路径把文件内容塞进提示词,还有反引号加感叹号的写法把 shell 输出注入进去。文档里这个例子把测试结果直接灌给模型:
---
description: Analyze test coverage
---
Here are the current test results:
!`npm test`
Based on these results, suggest improvements to increase coverage.
这里有一处值得停一下:模板里的 shell 片段是在渲染提示词的那一步就被交给配置的 shell 直接跑掉的,跑完把 stdout 贴回模板,然后整段才发给模型。源码里这一步只取了配置里的 shell 设置就直接执行,没有经过工具层那套 allow / ask / deny 的询问,也没有显式指定工作目录——也就是说它跟着 opencode 进程当时所在的目录走。这意味着你的权限配置管得住 Agent 自己想跑的命令,管不住你写在命令模板里的命令:那部分等同于你亲手敲下去的。所以命令文件应该跟代码一样进版本库、走评审,别从别人那儿复制一个看不懂的 ! 片段就放进 .opencode/commands/。subtask: true 可以强制让命令走子代理,不污染主会话上下文。把提示词固化成命令文件、再用 --command 在 CI 里调用,比在 YAML 里拼一大段中文提示词好维护得多。
至于要不要常驻服务:opencode serve 起一个无 TUI 的 HTTP 服务,opencode web 则是服务加网页界面,opencode attach [url] 把 TUI 接到已经跑起来的后端上。cli.mdx 给出的理由很实在——run 可以带 --attach 连上已有实例,避免每次执行都要等 MCP 服务冷启动:
# Start a headless server in one terminal
opencode serve
# In another terminal, run commands that attach to it
opencode run --attach http://localhost:4096 "Explain async/await in JavaScript"
判断标准可以简单点:单次任务、跑完就走,直接 run;同一批要连着跑十几次、又挂了好几个 MCP 服务,那冷启动成本乘以次数就不划算了,起个 serve。服务暴露出去的话,设 OPENCODE_SERVER_PASSWORD 开启 HTTP 基本认证,用户名默认 opencode,可以用 OPENCODE_SERVER_USERNAME 覆盖。这两个变量同时是 run --attach 和 attach 的 -p / -u 参数的默认值来源。
五、边界与代价:它明确不管的那些事
它不替你做人工确认。 非交互路径上 question 被 deny,Agent 没有”举手提问”这个动作。任务描述含糊、需求需要澄清的时候,它只会按自己的理解往下走。要留人工卡点,得靠外层流程——比如让它只产出 diff 不直接落盘,或者在两次 run 之间插入人工审阅。
attach 模式下的退出码不可靠。 run.ts 里等待结果的那个函数第一行就是:如果带了 --attach 就直接返回,不去取事件流里累积的错误。所以远端执行时会话内部报错不会体现在退出码上,你以为流水线绿了其实没做完。要判断成败,只能自己解析 --format json 的输出,看有没有 error 事件。本地执行则正常:session.error 事件累积后会把 exitCode 置 1,prompt 或 command 调用本身失败也是 1。
附件在 attach 模式下受限。 本地执行时 --file 传的是文件 URL 引用;attach 到远端时会把文件读成 base64 内联进去,代码里写死了 10 MiB 上限,超了直接报错退出,而且不允许附整个目录(没有共享文件系统,附目录没有意义)。
--mini 那条交互路径不是给脚本用的。 它是隐藏参数,要求 stdout 必须是 TTY,不能和 --command 同用,也不能和 --format json 同用,这些限制在 run.ts 里都是硬编码的提前退出。别指望在 CI 里用它。
它不管你的凭据卫生,也不管产出质量。 密钥存在本地明文 JSON 里,读了哪些文件发给了谁,靠你的权限配置约束。生成的代码有没有引入 bug,靠你的测试和评审。这些活儿它一件都不接。
长期运行的可观测性要自己搭。 --format json 给的是原始事件流,不是结构化的运行报告;opencode stats 给的是用量维度的统计。要串起”哪次跑挂了、挂在哪个工具上”,得自己落盘和聚合,思路可以参考 Agent 可观察日志怎么设计。
六、上手与避坑清单
在 CI 里第一次跑就该带 --format json,别等出问题再加。 会踩是因为默认输出是给人看的,机器解析不了,出错时你只有一段散文;一旦有事故想复盘,日志里那点东西不够用。怎么避:JSON 模式下每行一个事件对象,落盘留存,出事直接查 type 为 error 的那几行。
别在没配权限规则的情况下直接开 --auto。 会踩是因为一次性执行的默认拒绝确实让人烦,最省事的解法看起来就是加 --auto。但 auto 只是把”询问”变成”放行”,你的仓库里所有能被 shell 干的事它都能干。怎么避:先在配置里给 bash 写对象形式的规则,把 rm * 这类明确设成 deny,再开 auto;显式 deny 在 auto 下依然生效,这是文档写明的。
--fork 别单独用。 会踩是因为名字看起来像”新开一个分叉会话”,实际语义是”在继续某个会话之前先分叉”。怎么避:记住它必须搭配 --continue 或 --session,源码里有校验会直接退出。
别把 attach 模式的成功退出当成任务完成。 会踩是因为它和本地执行看起来一模一样,退出码却是两套逻辑。怎么避:远端执行一律走 JSON 输出自己判定,或者干脆在需要严格判定成败的场景下不用 attach。
管道输入会被追加到你的消息后面,不是替换。 会踩是因为你可能以为给了 - 或者管道就只用管道内容。实际是命令行消息在前、管道内容在后,中间一个换行。怎么避:想清楚提示词的位置——把指令写在命令行参数里,把待处理的内容从管道喂进去,顺序刚好是对的。
斜杠命令可以覆盖内置命令。 commands.mdx 明确写了自定义命令与同名内置命令冲突时,自定义的赢。会踩是因为你随手起了个 /share 或 /init,然后发现内置行为没了。怎么避:给项目命令加个前缀,别撞 /init、/undo、/redo、/share、/help 这几个。
opencode models 是查模型名的正道。 会踩是因为 --model 要的是 provider/model 形式的精确串,凭印象写十有八九拼错。怎么避:先 opencode models 列一遍,需要的话加 --refresh 刷新缓存、加 --verbose 看元数据,或者跟一个 provider ID 只看那一家的。
上下文自动压缩和 .claude 目录读取这类行为,都有对应的环境变量开关。 会踩是因为默认行为可能跟你的预期不一致,而你在参数表里找不到——它们在环境变量表里。怎么避:拿不准的时候把 cli.mdx 底部那两张表(环境变量与实验特性)通读一遍,名字都是 OPENCODE_ 开头,语义基本自解释。
收个尾
把这篇的判断压成一句话:opencode 的命令行有两种形态,交互形态假设有人在,脚本形态假设没人在,所有行为差异都是从这个假设推出来的——权限默认拒绝、不允许反问、输出按 TTY 与否自动换流、日志和正文分家。你只要抓住这条主线,参数怎么组合基本能自己推出来。
接下来该读哪几个文件,按你的目的挑:想搞清楚事件流里到底有什么,看 packages/opencode/src/cli/cmd/run.ts 的 loop 函数,它是全部输出行为的唯一出处;想拼命令行或者找某个开关,packages/web/src/content/docs/cli.mdx 一页看完;想把提示词工程化,packages/web/src/content/docs/commands.mdx 加上项目里的 .opencode/commands/ 目录;想收紧权限,packages/web/src/content/docs/permissions.mdx。
接完之后建议做一次自检:把 stdout 重定向到文件跑一次,确认拿到的是干净正文;故意触发一次需要权限的操作,确认拒绝路径的行为符合预期;再手动制造一次失败,确认你的流水线真的能感知到——尤其是用了 attach 的时候。这三条过了,才算把它接进了工程流程,而不是只在终端里玩过。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源终端 Agent opencode 的配置从哪读,改错一处为何全变 和 开源终端 Agent opencode 界面用熟:键位、主题与区块含义。