1. 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`.

probleme path

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?

.zshenv

Always(interactive + non-interactive + login)

Essential environment variables, PATH

✅ Yes — this is ITS place

.zprofile

Login shells only

Slow commands (once per session)

Possible

.zshrc

Interactive shells only

Alias, prompt, completion, interactive tools

❌ Not for critical variables

.zlogin

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.

  • .zprofile et `.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:

zsh init flow

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.

sdkman approach

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.

nvm vs sdkman

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

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:

nvm wrapper flow

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?

~/apps(gh, vscode)

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

  1. .zshrc is interactive, .zshenv is 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.

  2. 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.

  3. Do not source init scripts in .zshenvsdkman-init.sh et `nvm.sh`are designed for an interactive shell. They are slow and fragile in a non-interactive context.`current`symlinks are sufficient and instantaneous.

  4. 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.

  5. 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.

avant apres

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. _

Official Documentation

Mentioned Tools

Further Reading

Related articles