一句「你的终端配置借我看看」
事情的起点特别小。
我在终端里用惯了 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',退出码 2diff -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 格式。
同一个命令名,两个世界,各过各的。
评论交流
欢迎留下你的想法