OpenCode 和不完整的 PATH : 为什么您的工具不在代理的 shell 中
发布于 21 April 2026
您在终端中启动 OpenCode,一切正常。代理尝试一个`gh`— 找不到。一个`java -version`— 缺席。一个`node`— 无处可寻。然而,这些工具确实就在你的 votre shell 中。问题?OpenCode 启动了 shell。非交互式谁从不阅读您的`.zshrc`. 以下是诊断并正确修复它的方法。
场景:一位盲人智能体在工具的世界中
那是一个星期二的晚上。我刚刚安装了https://opencode.ai[开放代码], 这个承诺将改变我编码方式的AI代理。首次测试:让它列出我的GitHub仓库。
$ opencode
> Utilise gh pour lister mes repos
❌ bash: gh: command not found
奇怪。`gh`在我的终端里运行得很好。我尝试其他方法:
> Vérifie la version de Java
❌ bash: java: command not found
然后 :
> Lance le build Gradle
❌ bash: gradle: command not found
Java, Gradle, Node,gh— 对特工来说一切都是看不见的。 我的终端,然而,却看到了一切。 像特工和我生活在两个平行世界。
我花了几个小时才理解。几个小时的`echo $PATH`, de which java, 随着沮丧感日益加深。我终于意识到问题不在于工具——而在于贝壳. OpenCode, 如同启动子进程的所有代理一样,在 shell 中工作非交互的. 而 Zsh 在这些 shell 中,只是简单地忽略您的`.zshrc`。
以下是此诊断、解决方案以及我所学到的教训的完整叙述。如果您使用AI代理 — OpenCode、Aider、Cursor,甚至cron脚本 — 此问题终有一天会影响到您。
错误的解剖:两种shell,两种世界
当您打开终端时,Zsh 会将其视为一个 shell。交互的. 他充电`.zshrc`, 初始化一切:SDKMAN、NVM、pnpm、别名、你的漂亮提示符。你的环境已完整。
但是当 OpenCode 执行命令时,它不会启动交互式终端。它启动一个 shell。非交互式— 任务 shell,没有人在屏幕后面。而 Zsh,在这种情况下,跳跃。.zshrc. 他只读`.zshenv`。
为什么会有这种区别?因为非交互式 shell 旨在执行脚本,而不是为人类用户服务。在后台运行的脚本中加载别名、提示符和补全会是一种浪费。问题在于,您的 PATH —— 查找可执行文件的最关键信息 —— 常常是在`.zshrc`, 不在`.zshenv`.
@startuml skinparam backgroundColor #FEFEFE actor "您" as user actor "OpenCode\n(agent)" as agent participant "Shell\n交互式\n(.zshrc sourcé)" as ishell participant "Shell\n非交互式\n(.zshrc ignoré)" as nshell user -> ishell : gh, java, node ✅ agent -> nshell : gh, java, node ❌ note right of ishell Source .zshrc : - SDKMAN 初始化 - NVM 初始化 - ~/apps 在 PATH - pnpm 在 PATH end note note right of nshell .zshrc 已被忽略! PATH = /usr/bin:/bin + 一些默认目录 end note @enduml
问题的根源:Zsh 仅在交互式 shell 中 source .zshrc. OpenCode 与所有启动子进程的 IA 代理一样,使用非交互式 shell。这些 shell 仅读取.zshenv`。
罪魁祸首:.zshrc vs .zshenv
Zsh 具有四个初始化文件, 每个都有明确的角色。这是一种优雅的设计 — 但这也是问题的关键 :
| 文件 | 来源时 | 角色 | 修改 PATH 吗? |
|---|---|---|---|
|
总是(交互 + 非交互 + 登录) |
必需的环境变量, PATH |
✅是的—这是SA位置 |
|
仅限登录的shell |
慢速命令 (每会话一次) |
可能 |
|
仅交互式 Shell |
别名, 提示, 自动补全, 交互式工具 |
❌ 不适用于关键变量 |
|
登录 Shell(在 .zshrc 之后) |
欢迎消息, 完成 |
很少 |
表格给出一个线索,但必须深入理解。可以把它想成房子里的各个部分:
-
`.zshenv`是 该前厅— 所有人都会经过那里,无论是访客还是居民。如果你在这里放置某物,任何 shell 都能看到它。
-
`.zshrc`是客厅— 只有居民(交互式 shell)才会进入那里。访客(非交互式 shell)则停留在大厅。
-
.zprofileet `.zlogin`它们是登录 shell(例如在通过 SSH 登录时)的专用组件。
悲剧在于,我们大多数人把 PATH 放在客厅里。而 AI 代理却永远没有进入的权利。
问题的一个图示:
@startuml
skinparam backgroundColor #FEFEFE
start
if (Shell interactif ?) then (Oui)
:Source .zshenv;
:Source .zprofile;
:Source .zshrc;
:Source .zlogin;
note right
SDKMAN ✅
NVM ✅
~/apps ✅
pnpm ✅
end note
else (Non — shell non-interactif)
:Source .zshenv uniquement;
note right
SDKMAN ❌
NVM ❌
~/apps ❌
pnpm ❌
end note
endif
stop
@enduml
当您打开终端时,Zsh 是交互式的:它读取`.zshrc`, 一切正常。当 OpenCode 启动一个 shell 来执行命令时,Zsh 是非交互式的:它跳过`.zshrc`, 只读`.zshenv`. Et si `.zshenv`不存在或不包含 PATH — 那是荒原.
|
为什么Zsh会这样做?这是从Unix继承的设计选择。非交互式 shell 必须快速 et 可重复的. 将别名、彩色提示和 SDKMAN 的重型初始化加载到 cron 脚本或 AI 代理中会很慢且脆弱。因此这种分离是合理的: |
逐步诊断
在纠正之前,必须弄清楚到底缺少了什么。以下是一种可重复的诊断方法 — 如果您在使用 AI 代理,请将其收藏。
检查代理的PATH
我们将交互式终端所看到的内容与非交互式 shell 所看到的内容进行比较——也就是说,与 OpenCode 所看到的内容进行比较。
# Dans votre terminal interactif (tout fonctionne)
echo $PATH | tr ':' '\n' | grep -v '^/usr' | sort
典型结果:
/home/cheroliv/apps
/home/cheroliv/.nvm/versions/node/v22.19.0/bin
/home/cheroliv/.sdkman/candidates/java/current/bin
/home/cheroliv/.sdkman/candidates/gradle/current/bin
/home/cheroliv/.local/share/pnpm
/home/cheroliv/.local/bin
现在,模拟一下代理看到的内容——非交互式 Shell:
# Shell non-interactif : pas de .zshrc
zsh -c 'echo $PATH' | tr ':' '\n' | grep -v '^/usr' | sort
结果:
/home/cheroliv/.local/bin
六条路径中有五条已经消失。代理人丧失了其环境的83%。这就像让你在没有一半厨具的情况下做饭——你可能能煮水,但几乎做不了其他事情。
|
命令`zsh -c 'echo $PATH'`是你的第一号诊断工具. 如果您日常使用的某个工具不在结果中,您的AI代理也无法看到它。 在每次修改的之前和之后测试它`.zshenv`。 |
2. 识别 .zshrc 中存在但 .zshenv 中不存在的内容
现在,我们正在寻找罪魁祸首所在`.zshrc`. 我们过滤提及我们工具的 :
grep -n 'apps\|SDKMAN\|NVM\|PNPM\|PATH' ~/.zshrc
通常可以看到:
119: PATH="/usr/bin/python3:$HOME/apps:$PATH" # ← pas exporté !
171: export NVM_DIR="$HOME/.nvm"
172: [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # ← NVM init
176: nvm use --lts --silent # ← active une version
179: export PNPM_HOME="/home/cheroliv/.local/share/pnpm"
187: export SDKMAN_DIR="$HOME/.sdkman"
188: [[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"
一切都是不可见用于 OpenCode。和`PATH`第119行甚至没有`export`é — 它永不离开当前的 shell。这是一个关键的技术细节:一个没有`export`在定义它的 shell 中保持局部。如 OpenCode 启动的子 shell 从不继承它。
3. 检查.zshenv 是否存在
cat ~/.zshenv 2>/dev/null || echo "FICHIER ABSENT"
如果答案是 "FICHIER ABSENT",这就是一切取决的地方。
解决方案:.zshenv + 正确的路径
策略很简单,但它需要精确:放进`.zshenv` 仅基本路径,不加载沉重的初始化脚本。我们不移动`.zshrc`在`.zshenv`— 我们提取了精华。
创建.zshenv,包含所有必要的路径
`.zshenv`是唯一文件Zsh 保证在其中加载所有这些上下文。 这就是PATH应该去的地方。
export SDKMAN_DIR="$HOME/.sdkman"
export NVM_DIR="$HOME/.nvm"
export PNPM_HOME="$HOME/.local/share/pnpm"
export PATH="$HOME/apps:$HOME/.sdkman/candidates/java/current/bin:$HOME/.sdkman/candidates/gradle/current/bin:$HOME/.nvm/current/bin:$PNPM_HOME:$HOME/.local/bin:$HOME/.local/share/JetBrains/Toolbox/scripts:/usr/bin/python3:$PATH"
|
我们使用`$HOME/.nvm/current/bin`并且不`$HOME/.nvm/versions/node/v22.19.0/bin`. 原因:Node 版本会变化。硬编码的路径在下一次更新时会失效`nvm install`. 更多详情请见下一节。 |
为什么不 source sdkman-init.sh 在 .zshenv 中 ?
最诱人的方法当然就是简单地在…中复制`.zshenv`我们在…里所做的`.zshrc`— 为初始化脚本获取来源。我们可能有冲动去做:
# ❌ MAUVAISE IDÉE
[[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"
问题 :
-
慢(Empty output)`sdkman-init.sh`它在每个 shell 中执行网络解析和检查。在非交互式 shell 中,这是一种不必要的开销。
-
脆弱: SDKMAN 期望交互式上下文。其初始化可能在管道或子 shell 中静默失败。
-
无用: SDKMAN 将其候选人放置在`~/.sdkman/candidates/<tool>/current/bin`— 指向活动版本的稳定符号链接。我们可以使用它们。直接。
正确的方法是:跳过 SDKMAN 初始化,直接指向符号链接`current`. 这是整个解决方案的关键:使用文件结构作为合同,而不是初始化代码。
@startuml
skinparam backgroundColor #FEFEFE
rectangle "方法 LENTE\n(source sdkman-init.sh)" as slow {
card ".zshenv source sdkman-init.sh\n2-3秒 每shell\nVerications 网络\nRisque失败" as s1 #FDEDEC
}
rectangle "方法 RAPIDE\n(symlinks directs)" as fast {
card ".zshenv export PATH=...current/bin\n0 ms\nPas 网络的 \nPas 初始化的" as s2 #E8F8E8
}
slow --> fast : 最终结果相同\nLe PATH 指向 current/bin\n在两种情况下
@enduml
NVM 案例 : 当版本管理器忘记留下痕迹时
问题 NVM
这就是调查把我带到最远的地方。SDKMAN 有一个优雅的设计:当你`sdk install java 25.0.2-tem`, 它创建了一个符号链接 :
~/.sdkman/candidates/java/current -> ~/.sdkman/candidates/java/25.0.2-tem
这个符号链接是始终保持最新. 指向其上的在`.zshenv`而且你受到保护,无论使用哪种shell。很漂亮。
算了,他,不创建什么都没有电话。无符号链接`current`. 没有稳定的锚点。必须指向一个硬编码的版本化路径,看起来像这样:
~/.nvm/versions/node/v22.19.0/bin/node
到下一个`nvm install 24`, 这条路已经死了。 你的`.zshenv`它将指向一个不再是活动版本的版本。这是一个定时炸弹。
|
为什么 NVM 会这样做?NVM 通过动态修改 PATH 来在每次`nvm use`. 这是为经常在交互式 shell 中切换版本的开发者设计的。符号链接`current`它并不在最初的规格说明中 — 这是一个设计遗漏,我们将亲自纠正。 |
@startuml
skinparam backgroundColor #FEFEFE
package "SDKMAN ✅" {
[~/.sdkman/candidates/java/current] as sdk_current
[~/.sdkman/candidates/java/25.0.2-tem/] as java_25
[~/.sdkman/candidates/java/21.0.7-tem/] as java_21
sdk_current --> java_25 : 符号链接
}
package "NVM ❌ (before correction)" as nvm_before {
[~/.nvm/versions/node/v22.19.0/] as node_22
[~/.nvm/versions/node/v20.16.0/] as node_20
note right of node_22
不应该存在 `current` 的符号链接!
`.zshenv` 必须指向硬链接
在下次 `nvm` 安装时会损坏
end note
}
package "NVM ✅ (after correction)" as nvm_after {
[~/.nvm/current] as nvm_current
[~/.nvm/versions/node/v22.19.0/] as node_22b
[~/.nvm/versions/node/v20.16.0/] as node_20b
nvm_current --> node_22b : 符号链接\n(mis à jour auto)
}
@enduml
解决方案:为 NVM 创建符号链接 `current
因为 NVM 没有做这件事,我们自己来做。原理与 SDKMAN 相同 — — 一个符号链接`current`它始终指向活动版本。我们只需创建一次,然后将其自动化,使其自行更新。
# Créer le symlink initial
ln -sfn "$HOME/.nvm/versions/node/v22.19.0" "$HOME/.nvm/current"
现在,在`.zshenv`, 我们使用 :
$HOME/.nvm/current/bin
而不是:
# ❌ Chemin en dur — cassé au prochain changement de version
$HOME/.nvm/versions/node/v22.19.0/bin
自动更新符号链接
如果符号链接不更新,那就毫无价值。符号链接`current`必须在进行时进行更新`nvm use` ou nvm install. 解决方案:一个包装— 一个封装真实命令的函数`nvm`并在每次调用后更新符号链接。
# À la fin de .zshrc, APRÈS le chargement de NVM
nvm use --lts --silent
ln -sfn "$(nvm_version_path "$(nvm current)")" "$NVM_DIR/current"
_nvm() {
command nvm "$@"
local rc=$?
ln -sfn "$(nvm_version_path "$(nvm current)")" "$NVM_DIR/current"
return $rc
}
alias nvm='_nvm'
它是如何工作的,详细说明:
@startuml skinparam backgroundColor #FEFEFE actor Développeur participant "nvm 包装器\n(_nvm)" as wrapper participant "nvm 实际\n(command nvm)" as realnvm participant "~/.nvm/current\n(symlink)" as symlink Développeur -> wrapper : nvm 使用 20 wrapper -> realnvm : 命令 nvm use 20 realnvm --> wrapper : 已激活版本 wrapper -> symlink : ln -sfn .../v20.16.0 ~/.nvm/current wrapper --> Développeur : 返回 note right of symlink 始终保持最新! 由 .zshenv 使用 → 可通过 OpenCode 访问 end note @enduml
包装器`_nvm`调用真实命令`nvm`, 然后更新符号链接。 别名`nvm='_nvm'`使得在打字时`nvm`, 我们通过 wrapper。并且在 shell 初始化时,我们也这样做之后`nvm use --lts`.
|
`nvm_version_path`这是 NVM 的一个内部函数,用于解析某个版本的完整路径。这样可以避免手动重新构建路径。 |
路径汇总表
在进入陷阱之前,先看一下变换的视觉摘要。在左侧,您之前拥有的(全部在`.zshrc`, 对代理不可见)。 在右侧,您现在拥有的(基本路径在`.zshenv`, 到处可见)。
| 工具 | 仅限 .zshrc 之前 | 之后 (.zshenv + symlink) | 由 OpenCode 可见? |
|---|---|---|---|
|
PATH 未在 .zshrc 中导出 |
`$HOME/apps`在 .zshenv |
✅ |
SDKMAN Java |
`sdkman-init.sh`在 .zshrc 中 source |
`$HOME/.sdkman/candidates/java/current/bin`在 .zshenv |
✅ |
SDKMAN Gradle |
`sdkman-init.sh`在 .zshrc 中被 source |
`$HOME/.sdkman/candidates/gradle/current/bin`在 .zshenv |
✅ |
NVM 节点 |
`nvm use --lts`在 .zshrc |
`$HOME/.nvm/current/bin`在 .zshenv (符号链接) |
</think> ✅ |
pnpm |
`$PNPM_HOME`在 .zshrc 中 |
`$PNPM_HOME`在 .zshenv |
保留所有反引号代码段( |
JetBrains 工具箱 |
自动添加的由 Toolbox 在 .zshrc 中 |
`$HOME/.local/share/JetBrains/Toolbox/scripts`在 .zshenv |
✅ |
Python 3 |
`/usr/bin/python3`在 PATH .zshrc |
明确地在 .zshenv 中 |
✅ |
陷阱与缓解措施
这种方法并不完美。以下是我遇到的问题以及如何绕过它们。
| 陷阱 | 描述 | 缓解 |
|---|---|---|
复制的 PATH |
如果 .zshenv 和 .zshrc 添加相同的路径,该路径会出现两次 |
`.zshenv`已被来源之前.zshrc. 两者都在交互式 shell 中被读取。PATH 可能会重复。这是美观的,而不是功能性的。为了避免这种情况:只在 .zshenv 中放置那些在默认 PATH 中不存在的路径。 |
NVM : .zshenv 中硬编码的版本 |
放置`~/.nvm/versions/node/v22.19.0/bin`硬的在下一个变假`nvm install` |
使用符号链接`$HOME/.nvm/current/bin`+ 包装器`_nvm`在 .zshrc |
SDKMAN:在 .zshenv 中 source sdkman-init.sh |
缓慢、脆弱、在非交互式 shell 中无用 |
使用符号链接`candidates/<tool>/current/bin`直接 |
修改后缺少别名 |
包装器`nvm='_nvm'`在 .zshrc 中仅在重新加载后生效 |
重启 Shell 或`source ~/.zshrc` |
OpenCode 不会看到更改 |
代理已经使用旧的 .zshenv 启动了它的子 shell。 |
在修改 .zshenv 后重启 OpenCode |
.zshenv 过于臃肿 |
把重量级的函数或交互式初始化放在 .zshenv 中 |
.zshenv = 环境变量 + 仅 PATH。没有`source`, 没有重型函数,没有提示。 |
经验教训
-
.zshrc` 是交互式的,
.zshenv是通用的— 如果一个变量需要在 tous 的所有 shell(AI 代理、cron、脚本、IDE)中存在,它放置在`.zshenv`. 客厅很舒适,但入口大厅是唯一所有人都经过的地方。 -
版本管理器不相等— SDKMAN 创建符号链接`current`按设计。NVM 不。必须手动填补这一缺口。这是一个重要的教训:在配置你的 PATH 之前,检查你的管理器是否提供一个稳定的锚点。
-
不要在 .zshenv 中加载 init 脚本—
sdkman-init.shet `nvm.sh`它们是为交互式shell设计的。 它们在非交互式环境中很慢且脆弱。 符号链接`current`足够且即时。 -
始终在非交互式 shell 中测试—`zsh -c 'echo $PATH'`它精确地模拟了代理所看到的内容。这是验证测试。没有这个测试,你就不知道你的配置对代理是否有效。
-
包装器模式是可重用的— 同样的模式`_nvm`+`alias nvm='_nvm'`适用于任何动态修改 PATH 而不留下稳定痕迹的工具。这是你创意工具箱中的另一个工具。
@startuml
skinparam backgroundColor #FEFEFE
rectangle "之前" as avant {
card "交互式 Shell:✅\n非交互式 Shell:❌\nOpenCode:❌\nCron:❌\n脚本:❌" as av1 #FDEDEC
}
rectangle "之后" as apres {
card "交互式 Shell:✅\n非交互式 Shell:✅\nOpenCode:✅\nCron:✅\n脚本:✅" as ap1 #E8F8E8
}
avant --> apres : .zshenv +\nsymlink ~/.nvm/current
@enduml
最终验证
创建后`.zshenv`以及 NVM 符号链接,确认一切正常 — 在两种上下文中:
# Shell interactif (votre terminal)
gh --version && java -version && node --version
# Shell non-interactif (simulation OpenCode)
zsh -c 'gh --version && java -version && node --version'
两者都必须成功。如果是,您的 IA 代理将看到与您相同的工具。如果不是,请返回逐步诊断——您可能遗漏了一条路径,或者 NVM 的符号链接未更新。
|
自动化此测试。在您启动的每次工具更新后的 healthcheck 脚本中添加此检查。一`zsh -c 'which java && which node && which gh'`在CI中,这是一种防止意外的保险。 |
__ 一个非交互式shell就像一个沉默的客人:它只读取显示在入口门上的内容。如果PATH在客厅里,它永远不会看到它。 (Empty)
链接
官方文档
-
Zsh 文档 : 启动文件— 官方Zsh初始化文件参考。这就是所有内容被解释的地方,尽管经常被遗忘。
-
OpenCode:配置— 如何配置 OpenCode 及其运行环境。
提到的工具
-
NVM 在 GitHub 上— Node Version Manager. Node.js 版本管理器
-
SDKMAN — 官方网站— SDKMAN! JVM(Java、Kotlin、Gradle…)的版本管理器
-
SDKMAN:安装— SDKMAN 安装指南。
-
gh` — GitHub 命令行界面— GitHub 的命令行工具,对所有开发者来说必不可少。
-
Gradle — 官方网站— 我们通过 SDKMAN 安装的构建系统。
-
pnpm — 官方网站— Node.js 包管理器快速且节省空间。
-
JetBrains Toolbox— JetBrains IDE管理器,它将其脚本添加到PATH。
想要更深入
-
Zsh 配置文件:完整指南— 深入文章关于 Zsh dotfiles 管理。
-
Zsh 在 ArchWiki— Zsh 社区文档之一。
-
NVM 在 GitHub 上的问题— 要查看围绕符号链接的讨论`current`和 PATH问题。
相关文章
31 May 2026
14 May 2026
12 May 2026