OpenCode and the incomplete PATH: why your tools weren't in the agent's shell
Published on 21 April 2026
- The scene: a blind agent in a world of tools
- Anatomy of the bug: two shells, two worlds
- The culprits: .zshrc vs .zshenv
- Step-by-step diagnostic
- The solution: .zshenv + the right paths
- The NVM case: when the version manager forgets to leave a trace
- Summary table of paths
- Pitfalls and mitigations
- Lessons learned
- Final verification
- Links
-
reading time: 13 minutes
You launch OpenCode in your terminal, everything works. The agent tries a`gh`— not found. A`java -version`— absent. A`node`— nowhere. Yet, these tools are right there in your shell. The problem? OpenCode launches shellsnon-interactivethat never read your`.zshrc`. Here is how to diagnose and fix this properly.
- toc
-
[]
The scene: a blind agent in a world of tools
It was a Tuesday evening. I had just installedhttps://opencode.ai[OpenCode], the AI agent that promised to transform the way I code. First test: asking it to list my GitHub repositories.
$ opencode
> Utilise gh pour lister mes repos
❌ bash: gh: command not found
Strange.`gh`worked perfectly in my terminal. I try something else:
> Vérifie la version de Java
❌ bash: java: command not found
Then:
> Lance le build Gradle
❌ bash: gradle: command not found
Java, Gradle, Node,gh— everything was invisible to the agent. My terminal, however, saw everything. As if the agent and I lived in two parallel worlds.
It took me hours to understand. Hours of`echo $PATH`, de which java, of growing frustration. I eventually realized the problem wasn’t the tools — it was theshell. OpenCode, like any agent that launches subprocesses, works innon-interactiveshells. And Zsh, in those shells, simply ignores your`.zshrc`.
What follows is the full account of this diagnostic, the resolution, and the lesson I learned. If you use an AI agent — OpenCode, Aider, Cursor, or even cron scripts — this problem will affect you one day.
Anatomy of the bug: two shells, two worlds
When you open a terminal, Zsh treats it as aninteractiveshell. It loads`.zshrc`, which initializes everything: SDKMAN, NVM, pnpm, aliases, your pretty prompt. Your environment is complete.
But when OpenCode executes a command, it doesn’t launch an interactive terminal. It launches anon-interactiveshell — a task shell, with no human behind the screen. And Zsh, in this context, skips`.zshrc`. It only reads`.zshenv`.
Why does this distinction exist? Because a non-interactive shell is designed to execute scripts, not to serve a human user. Loading aliases, prompts, and completions in a script running in the background would be a waste. The problem is that your PATH — the most critical information for finding executables — is often initialized in`.zshrc`, not in`.zshenv`.
The root of the problem:Zsh only sources .zshrc for interactive shells. OpenCode, like any AI agent that launches subprocesses, uses non-interactive shells. These shells only read`.zshenv`.
The culprits: .zshrc vs .zshenv
Zsh hasfour initialization files, each with a specific role. It’s an elegant design — but it’s also the heart of the problem:
File |
Sourced when |
Role |
Modifies PATH? |
|
Always(interactive + non-interactive + login) |
Essential environment variables, PATH |
✅ Yes — this is ITS place |
|
Login shells only |
Slow commands (once per session) |
Possible |
|
Interactive shells only |
Alias, prompt, completion, interactive tools |
❌ Not for critical variables |
|
Login shells (after .zshrc) |
Welcome messages, finalization |
Rarely |
The table gives a hint, but it must be understood in depth. Think of it like the rooms of a house:
-
`.zshenv`is theentrance hall— everyone passes through here, visitor or resident. If you put something here, any shell will see it.
-
`.zshrc`is theliving room— only residents (interactive shells) enter. Visitors (non-interactive shells) stay in the hall.
-
.zprofileet `.zlogin`are specialized rooms for login shells (like when you connect via SSH).
The tragedy is that most of us put the PATH in the living room. And the AI agent is never allowed to enter.
The problem in a diagram:
When you open a terminal, Zsh is interactive: it reads`.zshrc`, everything works. When OpenCode launches a shell to execute a command, Zsh is non-interactive: it skips`.zshrc`, reads only`.zshenv`. Et si `.zshenv`doesn’t exist or doesn’t contain the PATH — it’s a desert.
|
Why does Zsh do this?It’s a design choice inherited from Unix. A non-interactive shell must befast et reproducible. Loading aliases, colored prompts, and heavy SDKMAN initializations in a cron script or an AI agent would be slow and fragile. The separation is therefore logical:`.zshenv`for the essentials (variables, PATH),`.zshrc`for convenience (aliases, prompt, completions). The problem arises when we put the essentials in the convenience section. |
Step-by-step diagnostic
Before fixing, you must understand exactly what is missing. Here is a reproducible diagnostic method — keep it bookmarked if you work with AI agents.
1. Verify the agent’s PATH
We compare what your interactive terminal sees with what a non-interactive shell sees — i.e., what OpenCode sees.
# Dans votre terminal interactif (tout fonctionne)
echo $PATH | tr ':' '\n' | grep -v '^/usr' | sort
Typical result:
/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
Now, let’s simulate what the agent sees — a non-interactive shell:
# Shell non-interactif : pas de .zshrc
zsh -c 'echo $PATH' | tr ':' '\n' | grep -v '^/usr' | sort
Result:
/home/cheroliv/.local/bin
Five out of six paths have disappeared.The agent is amputated from 83% of its environment. It’s like being asked to cook without half of your utensils — you could boil water, but not much else.
|
The command`zsh -c 'echo $PATH'`is yournumber 1 diagnostic tool. If a tool you use daily is missing from the result, your AI agent won’t see it either. Test it before and after every modification of`.zshenv`. |
2. Identify what is in .zshrc but not in .zshenv
Now, we look for the culprit in`.zshrc`. We filter the lines that mention our tools:
grep -n 'apps\|SDKMAN\|NVM\|PNPM\|PATH' ~/.zshrc
We typically find:
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"
All of this isinvisibleto OpenCode. And the`PATH`on line 119 isn’t even`export`exported — it never leaves the current shell. This is a crucial technical detail: a variable without`export`remains local to the shell that defines it. Sub-shells — like those launched by OpenCode — never inherit it.
3. Check if .zshenv exists
cat ~/.zshenv 2>/dev/null || echo "FICHIER ABSENT"
If the answer is "FILE NOT FOUND", that’s where everything happens.
The solution: .zshenv + the right paths
The strategy is simple, but requires precision: put in`.zshenv` onlythe essential paths, without sourcing heavy initialization scripts. We don’t move`.zshrc`into`.zshenv`— we extract the essentials.
Create .zshenv with all essential paths
`.zshenv`is theonly filethat Zsh guarantees to source inallcontexts. This is where the PATH should go.
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"
|
We use`$HOME/.nvm/current/bin`and not`$HOME/.nvm/versions/node/v22.19.0/bin`. The reason: Node versions change. A hardcoded path becomes false at the next`nvm install`. More details in the next section. |
Why not source sdkman-init.sh in .zshenv?
The most tempting approach would be to simply reproduce in`.zshenv`what we do in`.zshrc`— source the initialization scripts. Onemightbe tempted to do:
# ❌ MAUVAISE IDÉE
[[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"
Problems:
-
Slow:`sdkman-init.sh`does network resolutions and checks every shell. In a non-interactive shell, this is an unnecessary cost.
-
Fragile: SDKMAN expects an interactive context. Its initialization can fail silently in a pipe or a sub-shell.
-
Useless: SDKMAN places its candidates in`~/.sdkman/candidates/<tool>/current/bin`— stable symlinks pointing to the active version. We can use themdirectly.
The right approach: bypass SDKMAN initialization and point directly to the`current`symlinks. This is the key to the whole solution:use the file structure as a contract, rather than the initialization code.
The NVM case: when the version manager forgets to leave a trace
The NVM problem
This is where the investigation led me the furthest. SDKMAN has an elegant design: when we do`sdk install java 25.0.2-tem`, it creates a symlink:
~/.sdkman/candidates/java/current -> ~/.sdkman/candidates/java/25.0.2-tem
This symlink isalways up to date. Point to it in`.zshenv`and you are covered, regardless of the shell. Beautiful.
NVM, however, createsnothingof the sort. No`current`symlink. No stable anchor point. We have to point to a hardcoded versioned path, which looks like this:
~/.nvm/versions/node/v22.19.0/bin/node
At the next`nvm install 24`, this path is dead. Your`.zshenv`will point to a version that is no longer the active version. It’s a time bomb.
|
Why does NVM do this?NVM works by dynamically modifying the PATH at every`nvm use`. It’s designed for developers who change versions often, in an interactive shell. The`current`symlink was not in the initial specifications — it’s a design oversight that we will correct ourselves. |
The solution: create the current symlink for NVM
Since NVM doesn’t do it, we do it ourselves. The principle is the same as SDKMAN — a`current`symlink that always points to the active version. We create it once, then automate it so it updates itself.
# Créer le symlink initial
ln -sfn "$HOME/.nvm/versions/node/v22.19.0" "$HOME/.nvm/current"
Now, in`.zshenv`, we use:
$HOME/.nvm/current/bin
Rather than:
# ❌ Chemin en dur — cassé au prochain changement de version
$HOME/.nvm/versions/node/v22.19.0/bin
Automating the symlink update
A symlink is worthless if it doesn’t update. The`current`symlink must update when we do`nvm use` ou nvm install. The solution: awrapper— a function that wraps the real`nvm`command and updates the symlink after each call.
# À 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'
How it works, in detail:
The wrapper`_nvm`calls the real command`nvm`, then updates the symlink. The alias`nvm='_nvm'`ensures that by typing`nvm`, we go through the wrapper. And at shell initialization, we do the same after`nvm use --lts`.
|
`nvm_version_path`is an internal NVM function that resolves the full path of a version. This avoids rebuilding the path manually. |
Summary table of paths
Before moving to the pitfalls, a visual summary of the transformation. On the left, what you had (everything in`.zshrc`, invisible to the agent). On the right, what you have now (essential paths in`.zshenv`, visible everywhere).
| Tool | Before (.zshrc only) | After (.zshenv + symlink) | Visible to OpenCode? |
|---|---|---|---|
|
PATH not exported in .zshrc |
`$HOME/apps`in .zshenv |
✅ |
SDKMAN Java |
`sdkman-init.sh`sourced in .zshrc |
`$HOME/.sdkman/candidates/java/current/bin`in .zshenv |
✅ |
SDKMAN Gradle |
`sdkman-init.sh`sourced in .zshrc |
`$HOME/.sdkman/candidates/gradle/current/bin`in .zshenv |
✅ |
NVM Node |
`nvm use --lts`in .zshrc |
`$HOME/.nvm/current/bin`in .zshenv (symlink) |
✅ |
pnpm |
`$PNPM_HOME`in .zshrc |
`$PNPM_HOME`in .zshenv |
✅ |
JetBrains Toolbox |
Auto-added by Toolbox in .zshrc |
`$HOME/.local/share/JetBrains/Toolbox/scripts`in .zshenv |
✅ |
Python 3 |
`/usr/bin/python3`in PATH .zshrc |
Explicitly in .zshenv |
✅ |
Pitfalls and mitigations
Not everything is perfect with this approach. Here are the problems I encountered and how to bypass them.
| Pitfall | Description | Mitigation |
|---|---|---|
Duplicate PATH |
If .zshenv and .zshrc add the same path, it appears twice |
`.zshenv`is sourcedbefore.zshrc. Both are read in an interactive shell. The PATH can duplicate. It’s cosmetic, not functional. To avoid it: only put paths in .zshenv that are absent from the default PATH. |
NVM: hardcoded version in .zshenv |
Putting`~/.nvm/versions/node/v22.19.0/bin`hardcoded becomes false at the next`nvm install` |
Use the`$HOME/.nvm/current/bin`symlink + the`_nvm`wrapper in .zshrc |
SDKMAN: source sdkman-init.sh in .zshenv |
Slow, fragile, useless in a non-interactive shell |
Use`candidates/<tool>/current/bin`symlinks directly |
Missing alias after modification |
The`nvm='_nvm'`wrapper in .zshrc only takes effect after reloading |
Restart the shell or`source ~/.zshrc` |
OpenCode doesn’t see changes |
The agent has already launched its sub-shells with the old .zshenv |
Restart OpenCode after modifying .zshenv |
.zshenv too bloated |
Putting heavy functions or interactive initializations in .zshenv |
.zshenv = environment variables + PATH only. No`source`, no heavy functions, no prompts. |
Lessons learned
-
.zshrcis interactive,.zshenvis universal— If a variable must exist in all shells (AI agents, cron, scripts, IDE), it goes in`.zshenv`. The living room is comfortable, but the entrance hall is the only place where everyone passes. -
Version managers are not created equal— SDKMAN creates`current`symlinks by design. NVM does not. This gap must be filled manually. This is an important lesson: before configuring your PATH, check if your manager offers a stable anchor point.
-
Do not source init scripts in .zshenv—
sdkman-init.shet `nvm.sh`are designed for an interactive shell. They are slow and fragile in a non-interactive context.`current`symlinks are sufficient and instantaneous. -
Always test in a non-interactive shell—`zsh -c 'echo $PATH'`simulates exactly what an agent sees. This is the validation test. Without this test, you don’t know if your configuration works for agents.
-
The wrapper pattern is reusable— The same`_nvm`+`alias nvm='_nvm'`pattern applies to any tool that modifies the PATH dynamically without leaving a stable trace. It’s another tool in your idea box.
Final verification
After creating`.zshenv`and the NVM symlink, validate that everything works — in both contexts:
# Shell interactif (votre terminal)
gh --version && java -version && node --version
# Shell non-interactif (simulation OpenCode)
zsh -c 'gh --version && java -version && node --version'
Both must succeed. If so, your AI agent will see the same tools as you. If not, go back to the step-by-step diagnostic — you probably forgot a path or the NVM symlink is not up to date.
|
Automate this test.Add this check to a healthcheck script that you run after every update of your tools. A`zsh -c 'which java && which node && which gh'`in CI is insurance against surprises. |
_ A non-interactive shell is like a silent guest: it only reads what is posted on the front door. If the PATH is in the living room, it will never see it. _
Links
Official Documentation
-
Zsh documentation: Startup Files— The official reference on Zsh initialization files. This is where everything is explained, even if we often forget.
-
OpenCode: configuration— How to configure OpenCode and its execution environment.
Mentioned Tools
-
NVM on GitHub— Node Version Manager. Node.js version manager.
-
SDKMAN — official site— SDKMAN! The version manager for the JVM (Java, Kotlin, Gradle…)
-
SDKMAN: installation— SDKMAN installation guide.
-
gh— GitHub CLI— The GitHub command line tool, indispensable for any developer. -
Gradle — official site— The build system we install via SDKMAN.
-
pnpm — official site— The fast and space-efficient Node.js package manager.
-
JetBrains Toolbox— The JetBrains IDE manager, which adds its scripts to the PATH.
Further Reading
-
Zsh dotfiles: a complete guide— An in-depth article on managing Zsh dotfiles.
-
Zsh on ArchWiki— One of the best community documentations on Zsh.
-
NVM GitHub issues— To see discussions around the`current`symlink and PATH issues.