拆.
工具——AI 怎么'做事' · 第 07

Bash 的双语法树解析

跨平台一致性

4500读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:shell 命令的"看似简单实际复杂"

让 AI 跑 shell 命令听起来简单——LLM 输出一段命令字符串,agent 把它丢给系统跑、把输出回流。两段代码搞定。

但实际中这是 opencode 工程化复杂度最高的工具之一——bash 工具的实现(packages/opencode/src/tool/shell.ts)有 800+ 行代码。

为什么这么复杂?因为 shell 命令藏着 4 个看不见的难题。

难题 1:命令解析的歧义性

LLM 输出:"cat file.txt && ls -la | grep test"。这段命令里有什么?

  • cat file.txt — 一个命令
  • && — 逻辑与
  • ls -la — 另一个命令
  • | — 管道
  • grep test — 第三个命令

要解析它需要懂 shell 语法——管道、重定向、子 shell、变量替换、引号、转义。Bash 的语法规范是几百页的 POSIX 标准。

如果你只是简单把字符串扔给 exec()——会出 bug。某些字符(;|>)有特殊语义、引号嵌套规则复杂、变量替换可能展开成意外内容。

要正确处理,你需要一个 shell 语法解析器——把命令拆成语法树,然后理解每一部分。

难题 2:跨平台

ls 在 Linux/macOS 是列目录,在 Windows PowerShell 是 alias 到 Get-ChildItemrm -rf 在 Unix 删目录,在 Windows 报错。grep 在 Linux 内置,在 Windows 默认没有。

如果你的 Agent 要在多平台跑——Mac、Linux、Windows——你不能假设所有命令都一样。要么强制用 Unix 命令(Windows 用户痛苦)、要么强制用 PowerShell(Mac/Linux 用户痛苦)、要么两套都支持

opencode 选了"两套都支持"——这就是它有 Bash + PowerShell 双解析树的原因。

难题 3:安全

LLM 可能生成危险命令——rm -rf /sudo shutdowndd if=/dev/zero of=/dev/sda。这些命令一旦跑就不可逆——文件没了、系统挂了、数据丢了。

需要在执行前做安全检查。但检查"危险性"不能靠字符串匹配——rm -rf/tmp/test 是安全的,在 / 是灾难。判断危险性需要理解命令的实际语义。

这又回到难题 1——没有语法解析,你不能正确判断安全性

难题 4:超时和进程管理

LLM 跑 npm install 可能 30 秒,跑 pytest 可能 5 分钟。跑了之后用户按 Ctrl+C——子进程必须立刻被 kill,不能让它继续后台跑。

跨平台 kill 进程也不一样——Unix 用 SIGTERM/SIGKILL、Windows 用 TerminateProcess。要统一管理。

这 4 个难题加起来让 bash tool 的实现复杂——远超你看 child_process.exec("...") 想象的难度。

二、案例:opencode 的 shell.ts 双语法树

opencode 的 bash 工具实现在 packages/opencode/src/tool/shell.ts。它解决上面 4 个难题的方式让我们一个个看。

应对难题 1 + 2:双 tree-sitter 解析

opencode 用 tree-sitter——一个增量语法解析库——解析 shell 命令。它有两套语法解析:

  • tree-sitter-bash — 解析 Bash 语法
  • tree-sitter-powershell — 解析 PowerShell 语法

当 LLM 输出一段命令,opencode 根据当前平台选择哪个解析器:

  • macOS / Linux → Bash 解析器
  • Windows → PowerShell 解析器

解析后得到一棵 AST(抽象语法树)。每个节点是一个语法构件——command、pipeline、redirect、subshell。这棵树告诉 opencode 命令的实际结构

为什么需要两套?因为 Bash 和 PowerShell 的语法完全不一样

  • Bash: cat file.txt | grep test
  • PowerShell: Get-Content file.txt | Select-String "test"

不只是命令名不同——管道符的语义、变量展开规则、转义字符都不同。一套解析器搞不定两个语言。

opencode 选择维护两套,让每个平台都有"原生体验"。Mac/Linux 用户用 Bash 命令、Windows 用户用 PowerShell 命令、AI Agent 在哪个平台就生成哪种命令。

应对难题 3:基于 AST 的安全检查

有了 AST,opencode 可以做基于语义的安全检查。具体逻辑大概是:

analyzeCommand(ast):
  for each command in ast:
    if command.name in DANGEROUS_COMMANDS:
      // rm, mv, dd, sudo, chmod, chown ...
      check args for actual danger:
        if rm with -rf or -fr or recursive:
          if target is / or ~/ or relative:
            HIGH_RISK
        if mv to / or to system path:
          MEDIUM_RISK
        ...
      
    if command tries to write to:
      // /etc/, /usr/, /var/, /System/, C:\Windows ...
      HIGH_RISK
    
    if command tries to exec:
      // eval, source, . file
      MEDIUM_RISK (depends on file)

这种基于 AST 的检查比字符串匹配精准得多:

  • rm -rf /tmp/test — 低风险(删 /tmp 下的子目录)
  • rm -rf / — 极高风险(删整个根)
  • rm -rf $(echo /) — 通过 AST 看到 $(...) 是子命令,结果是 /——极高风险

检查通过后命令才执行;不通过则触发 permission ask——弹窗问用户"这命令看起来危险,确认要跑吗"。

应对难题 4:进程管理和 timeout

每个 bash 调用在 opencode 里启动一个独立的子进程。opencode 用 Node.js 的 child_process.spawn API,跨平台兼容。

每个进程关联一个 AbortController——用户中断或 timeout 触发时,controller 发 abort 信号,spawn 出来的进程被 kill。

Bash 工具的 description 里明确说:

Timeout in milliseconds. Defaults to 120000 (2 minutes). Maximum is 600000 (10 minutes).

默认 2 分钟、最大 10 分钟。如果命令需要更长——LLM 知道可以传更大的 timeout 参数。

Background 模式更特殊:

Set background to true to run a long-lived process and stream its output. The command runs detached and returns immediately with a handle id you can use to attach later.

跑 dev server、跑 watch 任务这种长命令,LLM 可以 background: true。bash 工具立刻返回一个 handle id,不等命令结束。后续可以通过 handle 查看输出或停止进程。

这种 background 设计让 AI Agent 能管理"长跑任务"——不需要把主循环卡在 npm run dev 上。

额外细节:cd 的特殊处理

shell 命令里 cd 比较特殊——它改的是当前进程的工作目录。但 opencode 每次 bash 调用是独立子进程——上一次 cd /tmp 对下一次没影响。

如果不处理这个,LLM 会困惑:

turn 1: bash "cd /tmp"  → 进程结束,cwd 没变
turn 2: bash "ls"        → 列的是项目目录,不是 /tmp

opencode 的处理:

  • 跟踪一个"会话级 cwd"(cwd state)
  • 检测到 cd 命令后,更新这个 state
  • 下次 bash 调用,在 state 对应的目录下执行

这样 LLM 的 cd "记得"——跨 bash 调用持续有效。

description 里也明确这个:

The active Location is the default working directory. Use the cd command to switch into another sandbox-accessible directory.

LLM 看到这个约束,知道可以用 cd 切目录。

三、设计启示:跨平台 AI 工具的真实复杂度

这一章的核心论点:让 AI 跑 shell 命令的"工程化复杂度"远超表面——涉及语法解析、跨平台、安全、进程管理多维度

如果你做 AI 产品有类似 bash 这种"动手能力强但风险大"的工具,下面几条原则有用:

1. 不要简单 wrap exec/spawn

直接把 LLM 输出丢给 exec() 是新手错误。你失去对命令的理解、失去安全检查、失去跨平台一致性。要用 AST 解析。

2. 跨平台从一开始就想

如果你的产品要跨平台跑,从 day 1 设计就要考虑。不要等"等以后再支持 Windows"——这种迁移成本极高。

opencode 一开始就支持两套语法,避免了后期重写。

3. 安全检查基于语义不是字符串

rm -rf /rm -rf ./tmp 字符串相似但风险完全不同。要 parse 后看真实目标。

4. 危险命令 → permission ask

不要静默拒绝(用户会困惑为啥不跑)、也不要静默执行(出事了用户怨你)。明确触发 permission 弹窗——让用户决定。这是 7.3 章会展开的 "ask" 状态的产品价值。

5. timeout 默认 + 可调

默认 timeout 防止 LLM 卡死循环。但要可调——某些场景就是需要长跑。

opencode 的 2 分钟默认、10 分钟最大是个合理范围。你的产品可能不一样——但要有"默认 + 上限"的设计。

6. background mode 处理长任务

dev server、watch 任务、queue worker——这些任务可能跑几小时。如果你的 AI 产品只有"同步等命令完"模式,用户会被卡住。

支持 background mode——立刻返回 handle id、命令后台跑、可查输出、可停止。

7. cwd 跨调用持久

如果你的产品多次 bash 调用是独立进程,要维护"会话级 cwd"——让 cd 跨调用有效。否则 LLM 会困惑。

8. AbortSignal 全链路

用户中断 → tool 取消 → fork 的 fiber kill → 子进程 SIGTERM/Terminate。每一层都要传递取消信号,否则中断不彻底。

最后一个观察。Bash 工具的实现复杂度是 AI Agent 工程化的经典案例——它展示了"看起来简单实际很难"的工具长什么样。

很多 AI 产品的 bash 工具实现是简单的 exec(command) + 字符串黑名单——看似省事,实际上:

  • 跨平台兼容差(Mac 上能跑的 Windows 跑不了)
  • 安全检查粗糙(rm 全禁太严、rm 不禁太松)
  • 进程泄漏(用户中断后命令还在跑)
  • cwd 不一致(cd 没用)

这些问题 demo 看不到——demo 都跑简单命令。但生产环境用户每天踩坑。

把 bash 工具做扎实——AST 解析、双语法、AbortSignal、cwd state、permission ask——是 AI Agent 工程师的"硬功夫"。这部分没人会教,要自己摸——但 opencode 的实现是个不错的参考。

下一章 3.8 我们看 opencode 最贵也最特别的工具——Task。它让 AI 把活分给"子 AI",是 multi-agent 编排的基础。