一句「你的终端配置借我看看」

事情的起点特别小。

我在终端里用惯了 eza、bat、rg 这些工具,ls、cat、grep 早就被别名接管了。有天我让 Claude Code 帮我跑几条命令,忽然想到一个问题:它看到的 ls,和我看到的是同一个吗?

如果它也被我的别名接管,那事情就麻烦了。它以为自己在跑 GNU ls,实际跑的是 eza;它按 find 的语法拼参数,实际执行的是 fd。命令没报错,结果却悄悄偏了。

我当时的第一反应是:那就把别名去掉,让它用原版。

第二反应是:可我自己的终端怎么办?我花了很久才把这套工具链调顺手。

于是问题变成了:有没有办法让同一个命令名,在我手里和在 AI 手里解析成两个不同的实现?

答案是有。但为了做出来,我花了整整一个下午,翻出了七八个平时根本不会注意的坑。

第一个意外:AI 的 Bash 不是我的 Bash

Claude Code 在 Windows 上调用 Bash 工具时,用的不是系统自带的 bash.exe,而是 Git Bash。这一点我早就知道。但我以为它每次都是干净启动,像 bash -c 一样只读环境变量。

实测之后才发现不是。

它的做法是:会话启动时先跑一次 source ~/.bashrc < /dev/null,把当时的别名、函数、shopt、PATH 全部导出成一个快照文件,放在 ~/.claude/shell-snapshots/ 下面。之后每一条 Bash 命令,都先 source 这个快照,再执行我的命令。

我数了一下,那个目录里已经堆了 87 个快照,每个 72KB 左右。它们就是一次次会话留下的 "环境化石"。

这个设计有它的道理:启动快,行为稳定,不用每次重新解析 profile。但它也意味着——你在 .bashrc 里写的东西,会原样泄漏给 AI。

我当时的 .bashrc 只有两行别名:

alias ls='eza'
alias cat='bat'

所以 Claude Code 那边的 ls,一直是 eza。它没出过错,只是运气好:我让它跑的 ls 大多只是看看目录,eza 的输出它也能读懂。

但这是运气,不是设计。

更巧的是,快照生成进程是 Claude Code 的子进程,环境里带着 CLAUDECODE 这个变量。这个细节后来成了整套方案的开关。

第二个意外:PowerShell 反而是干净的

同一台机器上,Claude Code 的 PowerShell 工具走的是另一条路。

它是用 pwsh -NoProfile -Command ... 启动的。-NoProfile 的意思是:不读任何 profile 文件。我的 profile.ps1 里那些 ls、cat、ll 的定义,它一个都看不到。

我实测确认了这一点:

# Claude 的 PowerShell 工具里
Get-Command ls    # Alias -> Get-ChildItem
Get-Command cat   # Alias -> Get-Content
Get-Command ll    # 不存在

而在自己的 PowerShell 窗口里,ls 是 eza、cat 是 bat、ll 是我自己写的函数。

所以 PowerShell 这边根本不需要 "分叉",它天然就是两条路。Bash 这边才需要动手。

双轨:让 .bashrc 自己判断对面是谁

思路很直接:既然快照生成进程带着 CLAUDECODE,那就用它当开关。

# ~/.bashrc
if [ -n "$CLAUDECODE" ]; then
    # 智能体:清掉 Git Bash 自带的系统别名
    unalias ls ll 2>/dev/null
else
    # 人类:加载增强别名
    [ -f "$HOME/.bash_aliases" ] && . "$HOME/.bash_aliases"
fi

这里有个容易忽略的点:Git Bash 在 /etc/profile.d/aliases.sh 里预置了 ls 和 ll 两个别名,而且它在 .bashrc 之前加载。光靠 "人类分支不定义" 是不够的,智能体侧必须主动 unalias,否则系统别名还在。

智能体分支里我只允许出现 unalias,不写任何 alias 和函数——这条后来被我写进了所有任务的硬约束里。

人类侧的别名全部集中到 ~/.bash_aliases,.bashrc 只负责 source。这样编排逻辑和别名数据分开了,回滚时不用在几十行里翻找。

PowerShell 侧照搬同样的结构:profile.ps1 只保留环境变量、PATH 去重和主题入口,别名搬到新建的 Aliases.ps1,文件顶部加一道守卫:

if ($env:CLAUDECODE -or $env:CLAUDE_CODE_ENTRYPOINT) { return }

return 在这里只退出被 dot-source 的脚本,不会把调用方的会话也一起终止。这一点我专门验证过。

排查实录(上):三种"看起来在测,其实没测"

配置写完之后,我开始写回归脚本。然后就被自己的测试骗了三次。

type -t 看不见别名

第一版脚本里,我用 type -t ls 判断智能体侧是不是原生命令,看到 file 就放心了。

后来我做了个注入实验:在智能体环境里故意加一条 alias ls='echo HIJACKED',再跑 type -t ls——它仍然返回 file。

原因是 bash 只在交互式 shell 里默认开启 expand_aliases。非交互的 bash -c 里,type 根本不看别名。

也就是说,那六条 "智能体侧没有别名" 的断言,恒为真。它通过了,但什么都没证明。

改成 alias -p 之后才可靠——alias 是内建命令,在非交互模式下照样能把别名表打出来。

pwsh -Command 把守卫也继承了

PowerShell 侧的回归更隐蔽。

脚本里写的是 pwsh -Command '...',我以为是 "干净启动"。但脚本本身是在 Claude 的 Bash 工具里跑的,环境里带着 CLAUDECODE。这个变量被 pwsh 子进程原样继承,Aliases.ps1 顶部的守卫立刻 return,于是所有人类侧断言全部失败。

失败信息看起来像配置坏了,其实是测试环境没清理干净。

修法是给每一条人类侧调用加前缀:

env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT pwsh -Command '...'

同一个坑还有第二层:profile 是在 -Command 之前加载的。所以你不能在命令里写 $env:CLAUDECODE="1" 来测守卫——那时候 profile 早就跑完了。要测守卫,必须在父进程控制变量,再启动子进程。

source 的退出码不是"加载成功"

我一开始用 source ~/.bashrc || exit 9 判断加载是否成功。

后来发现,source 的退出码等于文件里最后一条命令的退出码。我的 .bashrc 末尾是 eval "$(zoxide init bash)",现在返回 0;但只要哪天末尾多一句 [ -f xxx ] && cmd,而文件恰好不存在,就会返回 1。

文件语法完全正确、别名全部加载成功,测试却报 "加载失败"。

后来换成 bash -n ~/.bashrc:纯解析,不执行,退出码只表达语法是否合法。干净得多。

排查实录(下):有些命令,真的不能随便覆盖

前面的坑都还停留在 "测试怎么写"。后面这几个,是配置本身的设计问题。

diff 变成 delta 那天,我的管道全瞎了

delta 是个很好看的 diff 查看器,我一开始直接写了 alias diff='delta'。

人工看没问题。但我让审查代理跑了一组对照实验,结果很难看:

  • diff -q a b 直接报 unexpected argument '-q',退出码 2
  • diff -u a b 同样报错
  • diff -r dir1 dir2 也不行
  • diff <(cmd1) <(cmd2) 完全失效,报 Could not access '/proc/.../fd/63'
  • diff a b | grep x 匹配不到,因为输出里夹着 30 个 ANSI 转义字节
  • diff a b > out.txt 写出一个 564 字节的带色文件

原生 diff 在这些场景下都是纯文本、退出码 0/1/2 有明确含义。被 delta 接管之后,机器可读的那一面直接没了。

结论很简单:diff 是接口,不是展示。想看漂亮的差异,另起一个名字:

alias ddiff='delta'

ddiff a b 依然能看,diff 的所有脚本语义全部保留。

md 和 gl 不是我的

Bash 侧 md→glow、gl→git log 都没问题。搬到 PowerShell 就撞墙了:

  • md 是 mkdir 的内置别名
  • gl 是 Get-Location 的内置别名

PowerShell 的命令解析顺序是 Alias > Function > Cmdlet。如果我只是写一个 function md,它会被内置别名完全遮住,永远不执行——而且不会有任何报错。

所以这两个名字在 PowerShell 侧改成了 mdv 和 glog。

同一个机制还解释了另一个问题:ls 为什么能覆盖成功?因为脚本里先 Remove-Item Alias:ls,再定义函数。cat 一开始漏了这一步,在 5.1 上就出了问题。

5.1 的 cat 动不了

PowerShell 5.1 里,cat 这个内置别名带着 AllScope 选项。直接 Set-Alias cat bat 会失败:

The AllScope option cannot be removed from the alias 'cat'.

而脚本里恰好用了 -ErrorAction SilentlyContinue,错误被静默吞掉。表面上一切正常,只有 $Error.Count 悄悄变成了 1。

修法是把 cat 也加进 "先删后设" 的循环:

foreach ($a in 'll', 'la', 'tree', 'cat') {
    Remove-Item -LiteralPath "Alias:$a" -ErrorAction Ignore
}

这里用 Ignore 而不是 SilentlyContinue,因为后者虽然不显示错误,仍然会往 $Error 里记一条。

没有 BOM 的中文注释,把 5.1 骗了

这是整个下午最值钱的一个发现。

我给 Aliases.ps1 加了一行中文注释,pwsh 7 跑得好好的,5.1 直接报:

At Aliases.ps1:9 char:1
+ }
+ ~
Unexpected token '}' in expression or statement.

然后所有别名整段消失。

原因是 5.1 读取没有 BOM 的 .ps1 时,按系统 ACP 解码——这台机器上是 936,也就是 GBK。UTF-8 的中文是 3 字节一个字,GBK 按两字节配对时,可能把行尾的 LF 当成某个字的第二个字节吃掉。下一行就被并进了注释。

证据是行数:pwsh 7 看到 64 行,5.1 只看到 58 行。丢了 6 行,没报编码错误,只是默默合并。

更阴的一点是:这个文件原本就有 5 处中文注释,但它们后面跟的都是注释或空行,合并了也无害。我新加的那行后面紧跟 foreach,合并之后代码变成注释,} 就成了孤立的 token。

我试过把注释改成 ASCII,5.1 能过,但仍然只看到 59 行——另外几处中文注释还在悄悄合并。触发点取决于整个文件的字节配对对齐,不是单行奇偶,没法靠 "数字节" 预判。

于是给两个会被 5.1 加载的文件都加了 UTF-8 BOM:

  • Documents\PowerShell\Aliases.ps1(7 和 5.1 共用)
  • Documents\WindowsPowerShell\profile.ps1(5.1 专用)

加完之后,两端都看到 64 行,$Error.Count 归零。

两个顺手记下的小坑

排查行尾时,我用 grep -c $'\r' 统计 CR 字节,对两个纯 LF 文件分别报出了 36 和 69——恰好等于行数。因为 $'\r' 在命令传输过程中被剥掉,变成了空模式,空模式匹配每一行。

可靠写法是字节级计数:

tr -cd '\r' < file | wc -c

另一个是编辑工具:某次改完 .bash_aliases,整个文件的行尾从 LF 变成了 CRLF。对被 source 的 bash 文件来说,这不是格式问题——alias x='...' 行尾的 \r 会混进别名值里。后来每次编辑完都补一条 CR 计数检查。

回归门禁:从"存在"长到"行为"

配置改完,真正的难点是怎么证明它是对的。

第一版脚本只会检查 "别名在不在"。它漏掉了 ls 被系统别名冒充、diff 被 delta 接管、cat 被 AllScope 挡住这些情况。

后来的版本改成了三层:

第一层看文件本身:BOM、行尾、bash -n、PowerShell 的 ParseFile。

第二层看加载结果:alias -p 的输出、Get-Command 的 CommandType、Get-Alias 的 Definition。PowerShell 人类侧的 25 个别名 / 函数现在逐项对表,函数必须是 Function,别名必须指向正确的目标。

第三层看真实行为:5.1 用 powershell.exe 真实加载一遍,断言 ll 是函数、cat 是 bat、$Error.Count 是 0。

第三层是后来补的。在那之前,脚本里只有 pwsh 7 的语法解析——而 5.1 的编码故障在 pwsh 7 下是 0 错误。也就是说,那道门禁对已经发生过的故障必然给绿灯。

我还做了一次负向验证:伪造一个 USERPROFILE,放一份去掉 BOM、还原了故障注释的 Aliases.ps1,用同样的表达式跑 5.1。结果是 ll 消失、cat 退回 Get-Content、$Error.Count=2,门禁按预期变红。

现在这套脚本一共 75 条断言,PASS=75 FAIL=0。

现在的样子

命令 Bash 人类 Bash 智能体 PS 7 人类 PS 7 智能体 PS 5.1 人类
ls eza 原生 eza Get-ChildItem eza
cat bat(不分页) 原生 bat Get-Content bat
ll/la/tree eza 系列 无 eza 系列 无 eza 系列
md glow 无 mdv(md 仍是 mkdir) mkdir mdv
gl git log 无 glog(gl 仍是 Get-Location) Get-Location glog
diff 原生 原生 Compare-Object Compare-Object Compare-Object
ddiff delta 无 — — —
find/grep/ssh/curl/rm 原生(仅注释) 原生 原生 原生 原生

文件分工也定了下来:

~/.bashrc                          # 双轨分支 + 环境变量
~/.bash_aliases                    # Bash 人类别名
~/.config/bash/broot.sh            # br 函数
~/Documents/PowerShell/profile.ps1 # 编排:环境变量、PATH、主题
~/Documents/PowerShell/Aliases.ps1 # 人类别名(UTF-8 BOM)
~/Documents/PowerShell/broot.ps1   # br 函数
~/Documents/WindowsPowerShell/profile.ps1  # 5.1 复用 Aliases.ps1(UTF-8 BOM)

每个文件都留了改动前的备份。备份不是越多越好,清理时我按 "目标文件" 分组,每组只留最新一版——真正有用的是 "最近一次改动前" 那一份。

重启 Claude Code 之后,新快照里 alias -- ls= 的出现次数是 0。那一刻我才确定,这次不是 "文件写对了",是 "运行时真的走对了"。

几条真心得

先问 "谁在什么环境里执行",再写第一行配置。 同一个命令名,在四个地方可能有四种含义:我的 Bash、AI 的 Bash、我的 PowerShell、AI 的 PowerShell。不把执行环境列清楚,后面所有判断都是猜。

最危险的失败长得像成功。 5.1 丢了几行还能定义 ll;ls 被内置别名遮住时 Get-Command ls 仍有返回;非交互 type -t 对别名恒报 file。这些都不会抛异常,只会让结果悄悄偏掉。判断一个测试有没有用,就看它能不能在 "故意搞坏" 的时候变红。

语法通过不等于能加载,能加载不等于目标对。 文件级检查、加载结果、真实行为,三层缺一不可。PWSh 7 的 ParseFile 对 5.1 的编码问题一无所知,这就是只用第一层的代价。

改名比覆盖安全。 ddiff、mdv、glog 都是因为原名带着不可替代的语义。覆盖一个常用命令,收益是少敲几个字母,代价是某天某个脚本突然不工作,而你还不知道是谁改的。

让 AI 自己验证,但别让它自己给自己打分。 这次每个改动都有独立的审查代理,负责跑反向实验、找假阳性。它抓出的最有价值的问题,全都是 "测试通过了但没测到"——这种问题,写代码的人自己很难看见。

配置是活的,验证要跟着环境走。 快照会过期,profile 要重启,5.1 和 7 的编码规则不同。改完不重启,测的永远是旧世界。

那天晚上收工前,我又开了一个新终端,敲了 ls。图标、颜色、目录优先,一切照旧。然后打开 Claude Code,让它跑同样的 ls,输出是干净的 GNU 格式。

同一个命令名,两个世界,各过各的。