阅读时间 : 13 minutes

您在终端中启动 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 吗?

.zshenv

总是(交互 + 非交互 + 登录)

必需的环境变量, PATH

✅是的—这是SA位置

.zprofile

仅限登录的shell

慢速命令 (每会话一次)

可能

.zshrc

仅交互式 Shell

别名, 提示, 自动补全, 交互式工具

❌ 不适用于关键变量

.zlogin

登录 Shell(在 .zshrc 之后)

欢迎消息, 完成

很少

表格给出一个线索,但必须深入理解。可以把它想成房子里的各个部分:

  • `.zshenv`是 该前厅— 所有人都会经过那里,无论是访客还是居民。如果你在这里放置某物,任何 shell 都能看到它。

  • `.zshrc`是客厅— 只有居民(交互式 shell)才会进入那里。访客(非交互式 shell)则停留在大厅。

  • .zprofile et `.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 代理中会很慢且脆弱。因此这种分离是合理的:.zshenv`主要 (variables, PATH).zshrc`为了舒适(别名、提示、补全)。问题出现在我们把基本要素放在舒适里时。

逐步诊断

在纠正之前,必须弄清楚到底缺少了什么。以下是一种可重复的诊断方法 — 如果您在使用 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 可见?

~/apps(gh, vscode)

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`, 没有重型函数,没有提示。

经验教训

  1. .zshrc` 是交互式的, .zshenv 是通用的— 如果一个变量需要在 tous 的所有 shell(AI 代理、cron、脚本、IDE)中存在,它放置在`.zshenv`. 客厅很舒适,但入口大厅是唯一所有人都经过的地方。

  2. 版本管理器不相等— SDKMAN 创建符号链接`current`按设计。NVM 不。必须手动填补这一缺口。这是一个重要的教训:在配置你的 PATH 之前,检查你的管理器是否提供一个稳定的锚点。

  3. 不要在 .zshenv 中加载 init 脚本—sdkman-init.sh et `nvm.sh`它们是为交互式shell设计的。 它们在非交互式环境中很慢且脆弱。 符号链接`current`足够且即时。

  4. 始终在非交互式 shell 中测试—`zsh -c 'echo $PATH'`它精确地模拟了代理所看到的内容。这是验证测试。没有这个测试,你就不知道你的配置对代理是否有效。

  5. 包装器模式是可重用的— 同样的模式`_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)

链接

官方文档

提到的工具

想要更深入

相关文章