leestijd: 13 minutes

Je start OpenCode in je terminal, alles werkt. De agent probeert een`gh`— niet te vinden. Een`java -version`— afwezig. een`node`— nergens. Desondanks zijn deze tools er gewoon in jouw shell. Het probleem? OpenCode lanceert shellsniet-interactiefdie nooit uw lezen`.zshrc`. Zo kun je dit netjes diagnosticeren en oplossen.

De scène: een blinde agent in een wereld van tools

Het was dinsdagavond. Ik had net geïnstalleerdhttps://opencode.ai[OpenCode], de AI-agent die beloofde mijn manier van coderen te transformeren. Eerste test: hem vragen om mijn GitHub-repositories op te lijsten.

$ opencode
> Utilise gh pour lister mes repos
❌ bash: gh: command not found

Vreemd.`gh`werkte perfect in mijn terminal. Ik probeer iets anders :

> Vérifie la version de Java
❌ bash: java: command not found

Dan :

> Lance le build Gradle
❌ bash: gradle: command not found

Java, Gradle, Node,gh— niets was onzichtbaar voor de agent. Mijn terminal, hij, zag alles. Alsof de agent en ik in twee parallelle werelden leefden.

Ik deed er uren over om te begrijpen. Uren van`echo $PATH`, de which java, van toenemende frustratie. Ik begon te beseffen dat het probleem niet de tools waren — het was deschil. OpenCode, net als elke agent die sub-processen start, werkt in shellsniet-interactief. En Zsh, in deze shells, negeert gewoon uw`.zshrc`.

Dit volgende is het volledige verhaal van deze diagnose, van de oplossing en van de les die ik heb geleerd. Als u een AI-agent gebruikt — OpenCode, Aider, Cursor, of zelfs cron-scripts — zal dit probleem ooit van toepassing zijn op u.

Anatomie van de bug: twee shells, twee werelden

Wanneer je een terminal opent, behandelt Zsh het als een shellinteractief. Hij laadt`.zshrc`, wat alles initialiseert: SDKMAN, NVM, pnpm, de aliassen, je mooie prompt. Je omgeving is compleet.

Maar wanneer OpenCode een opdracht uitvoert, start het geen interactieve terminal. Het start een shell.niet-interactief— een taakshell, zonder mens achter het scherm. En Zsh, in deze context, springt`.zshrc`. Hij leest alleen`.zshenv`.

Waarom bestaat dit onderscheid? Omdat een niet-interactieve shell is ontworpen om scripts uit te voeren, niet om een menselijke gebruiker te dienen. Het laden van aliassen, de prompt en de aanvullingen in een script dat op de achtergrond draait, zou verspilling zijn. Het probleem is dat uw PATH — de belangrijkste informatie om uitvoerbare bestanden te vinden — vaak wordt geïnitialiseerd in`.zshrc`, niet in`.zshenv`.

@startuml
skinparam backgroundColor #FEFEFE

actor "U" as user
actor "OpenCode\n(agent)" as agent
participant "Interactieve\nshell\n(.zshrc geladen)" as ishell
participant "Niet-interactieve\nshell\n(.zshrc genegeerd)" as nshell

user -> ishell : gh, java, node ✅
agent -> nshell : gh, java, node ❌

note right of ishell
Bron .zshrc :
- SDKMAN init
- NVM init
- ~/apps in PATH
- pnpm in PATH
end note

note right of nshell
.zshrc genegeerd !
PATH = /usr/bin:/bin
+ enkele standaardmappen
end note

@enduml

De kern van het probleem :Zsh source .zshrc alleen voor interactieve shells. OpenCode, net als elke IA-agent die subprocessen start, gebruikt niet-interactieve shells. Deze shells lezen alleen`.zshenv`.

De schuldigen: .zshrc vs .zshenv

Zsh heeftvier initialisatiebestanden, elk met een nauwkeurige rol. Het is een elegant ontwerp — maar dat is ook de knoop van het probleem:

Bestand Bron wanneer Rol Pas het PATH aan?

.zshenv

Altijd(interactief + niet-interactief + login)

Essentiële omgevingsvariabelen, PATH

✅ Ja — het is SA plaats

.zprofile

Alleen login-shells

Langzame opdrachten (een keer per sessie)

mogelijk

.zshrc

Alleen interactieve shells

Alias, prompt, completie, interactieve tools

❌ Niet voor kritieke variabelen

.zlogin

Inlogshells (na .zshrc)

Welkomstberichten, afronding

zelden

De tabel geeft een aanwijzing, maar je moet het grondig begrijpen. Denk er aan als de kamers van een huis:

  • `.zshenv`is deentreehal— iedereen passeert hier, bezoeker of inwoner. Als je iets hier plaatst, kan elke shell het zien.

  • `.zshrc`is dewoonkamer— alleen de bewoners (interactieve shells) gaan er binnen. De bezoekers (niet-interactieve shells) blijven in de hal.

  • .zprofile et `.zlogin`zijn speciale onderdelen voor login-shells (zoals wanneer je inlogt via SSH).

Het drama is dat de meeste van ons de PATH in de woonkamer plaatsen. En de AI-agent, hij, heeft nooit het recht daar binnen te gaan.

Het probleem in één diagram :

@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

Wanneer je een terminal opent, is Zsh interactief: het leest`.zshrc`, alles werkt. Wanneer OpenCode een shell start om een opdracht uit te voeren, is Zsh niet-interactief: het slaat over`.zshrc`, leest alleen`.zshenv`. Et si `.zshenv`bestaat niet of bevat niet het PATH — het is de woestijn.

Waarom doet Zsh dat?Dit is een ontwerpkeuze die is overgenomen van Unix. Een niet-interactieve shell moet zijnsnel et reproduceerbaar. Laden van aliases, gekleurde prompts en zware initialisaties van SDKMAN in een cron-script of een IA-agent zou traag en breekbaar zijn. De scheiding is dus logisch :`.zshenv`voor het grootste deel (variabelen, PATH)`.zshrc`voor het comfort (aliases, prompt, vullen aan). Het probleem treedt op wanneer we het essentiële in het comfort plaatsen.

Stap-voor-stap diagnose

Voordat je corrigeert, moet je begrijpen wat er precies ontbreekt. Hier is een reproduceerbare diagnostische methode — bewaar deze in je favorieten als je werkt met AI-agents.

1. Controleer het PATH van de agent

We vergelijken wat je interactieve terminal ziet met wat een niet-interactieve shell ziet — dat wil zeggen wat OpenCode ziet.

# Dans votre terminal interactif (tout fonctionne)
echo $PATH | tr ':' '\n' | grep -v '^/usr' | sort

Typisch resultaat :

/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

Laten we nu simuleren wat de agent ziet — een niet-interactieve shell:

# Shell non-interactif : pas de .zshrc
zsh -c 'echo $PATH' | tr ':' '\n' | grep -v '^/usr' | sort

Resultaat:

/home/cheroliv/.local/bin

Vijf van de zes paden zijn verdwenen.De agent is voor 83% geamputeerd van zijn omgeving. Het is alsof je wordt gevraagd om te koken zonder de helft van je keukengerei — je zou water kunnen koken, maar niet veel meer.

de opdracht`zsh -c 'echo $PATH'`is jouwdiagnosetool nummer 1. Als een tool die je dagelijks gebruikt ontbreekt in het resultaat, kan je IA-agent hem ook niet zien. Test het voor en na elke wijziging van`.zshenv`.

2. Identificeer wat er in .zshrc zit maar niet in `.zshenv

Nu zoeken we de schuldige in`.zshrc`. We filteren de regels die onze tools vermelden :

grep -n 'apps\|SDKMAN\|NVM\|PNPM\|PATH' ~/.zshrc

Men vindt typisch:

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"

Dit alles isonzichtbaarvoor OpenCode. En het`PATH`van regel 119 is zelfs niet`export`é — het verlaat nooit de huidige shell. Dit is een cruciaal technisch detail: een variabele zonder`export`blijft lokaal bij de shell die het definieert. Subshells — zoals die gestart worden door OpenCode — erven het nooit.

3. Controleren of .zshenv bestaat

cat ~/.zshenv 2>/dev/null || echo "FICHIER ABSENT"

Als het antwoord « FICHIER ABSENT » is, daar wordt alles beslist.

De oplossing : .zshenv + de juiste paden

De strategie is eenvoudig, maar ze vraagt om precisie: plaatsen in`.zshenv` alleende essentiële paden, zonder de zware initialisatiescripts te bronnen. Men verplaatst niet`.zshrc`in`.zshenv`— we halen de essentie.

Maak .zshenv met alle essentiële paden

`.zshenv`is deenkel bestanddat Zsh garandeert om te sourcer inallede contexten. Dit is waar de PATH naartoe moet.

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 gebruiken`$HOME/.nvm/current/bin`en niet`$HOME/.nvm/versions/node/v22.19.0/bin`. De reden: de Node-versies veranderen. Een hard gecodeerd pad wordt onjuist bij het volgende`nvm install`. Meer details in de volgende sectie.

Waarom niet source sdkman-init.sh in .zshenv ?

De meest verleidelijke benadering zou zijn om eenvoudigweg te reproduceren in`.zshenv`wat we doen in`.zshrc`— de initialisatiescripts ophalen. Onzou kunnenverleed zijn om te doen :

# ❌ MAUVAISE IDÉE
[[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"

Problemen :

  • traag : `sdkman-init.sh`voert netwerkresoluties en controles uit bij elke shell. In een niet-interactieve shell is het een onnodige kosten.

  • Fragiel: SDKMAN verwacht een interactieve context. De initialisatie kan stil falen in een pijpleiding of een subshell.

  • Nutteloos: SDKMAN plaatst zijn kandidaten in`~/.sdkman/candidates/<tool>/current/bin`— stabiele symlinks die naar de actieve versie wijzen. Men kan ze gebruikenrechtstreeks.

De juiste aanpak: de initialisatie van SDKMAN omzeilen en direct naar de symlinks wijzen`current`. Dit is de sleutel van de hele oplossing :gebruik de bestandsstructuur als contract, in plaats van de initialisatiecode.

@startuml
skinparam backgroundColor #FEFEFE

rectangle "Benadering LENTE\n(source sdkman-init.sh)" as slow {
  card ".zshenv source sdkman-init.sh\n2-3 seconden per shell\nNetwerkcontroles\nRisico op falen" as s1 #FDEDEC
}

rectangle "Benadering RAPIDE\n(directe symlinks)" as fast {
  card ".zshenv export PATH=...current/bin\n0 ms\nGeen netwerk\nGeen initialisatie" as s2 #E8F8E8
}

slow --> fast : zelfde eindresultaat\nLe PATH wijst op current/bin\nin de twee gevallen

@enduml

Het NVM-geval: wanneer de versiebeheerder vergeet een spoor achter te laten

Het probleem NVM

Hier heeft het onderzoek mij het verst gebracht. SDKMAN heeft een elegant ontwerp: wanneer men`sdk install java 25.0.2-tem`, hij maakt een symlink aan :

~/.sdkman/candidates/java/current -> ~/.sdkman/candidates/java/25.0.2-tem

Deze symlink isaltijd up-to-date. Point erop in`.zshenv`en je bent gedekt, welk shell het ook is. Mooi.

NVM, hij, creëert nietnietsvan tel. Geen symlink`current`. Geen stabiel ankerpunt. We moeten wijzen op een hardgecodeerde versiepad, dat er zo uitziet:

~/.nvm/versions/node/v22.19.0/bin/node

bij de volgende`nvm install 24`, dit pad is dood. uw`.zshenv`Het zal wijzen naar een versie die niet langer de actieve versie is. Dat is een tijdbom.

Waarom doet NVM dat?NVM werkt door de PATH dynamisch bij elke`nvm use`. Het is ontworpen voor ontwikkelaars die vaak van versie wisselen, in een interactieve shell. De symlink`current`was niet in de initiële specificaties — het is een ontwerpfout die wij zelf gaan corrigeren

@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 : symlink
}

package "NVM ❌ (voor correctie)" 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
Geen symlink current!
.zshenv moet hardcoded verwijzen
Kapot bij de volgende nvm install
  end note
}

package "NVM ✅ (na correctie)" 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 : symlink\n(mis à jour auto)
}

@enduml

Aangezien NVM het niet doet, doen we het zelf. Het principe is hetzelfde als SDKMAN — een symlink`current`Het wijst altijd naar de actieve versie. We maken het één keer aan, en automatiseren het vervolgens zodat het zichzelf up-to-date houdt.

# Créer le symlink initial
ln -sfn "$HOME/.nvm/versions/node/v22.19.0" "$HOME/.nvm/current"

Nu, in`.zshenv`, men gebruikt :

$HOME/.nvm/current/bin

In plaats van:

# ❌ Chemin en dur — cassé au prochain changement de version
$HOME/.nvm/versions/node/v22.19.0/bin

Een symlink is niets waard als hij niet wordt bijgewerkt. De symlink`current`moet zich bijwerken wanneer men doet`nvm use` ou nvm install. De oplossing: eenomhulsel— een functie die de echte opdracht omhulst`nvm`en werkt de symlink bij na elke aanroep.

# À 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'

Hoe het werkt, in detail:

@startuml
skinparam backgroundColor #FEFEFE

actor Développeur
participant "nvm wrapper\n(_nvm)" as wrapper
participant "echte nvm\n(commando nvm)" as realnvm
participant "~/.nvm/current\n(symlink)" as symlink

Développeur -> wrapper : nvm use 20
wrapper -> realnvm : command nvm use 20
realnvm --> wrapper : version activée
wrapper -> symlink : ln -sfn .../v20.16.0 ~/.nvm/current
wrapper --> Développeur : retour

note right of symlink
Altijd up-to-date!
Gebruikt door `.zshenv`
→ toegankelijk via OpenCode
end note

@enduml

De wrapper`_nvm`roept de echte opdracht`nvm`, werkt vervolgens de symlink bij. Het alias`nvm='_nvm'`Zorg ervoor dat bij het typen`nvm`, we gaan via de wrapper. En bij de initialisatie van de shell, doen we hetzelfde daarna`nvm use --lts`.

`nvm_version_path`Dit is een interne functie van NVM die het volledige pad van een versie bepaalt. Het voorkomt dat je het pad handmatig hoeft op te bouwen.

Overzichtstabel van de paden

Voordat je overgaat op de vallen, een visueel overzicht van de transformatie. Aan de linkerkant, wat je had (alles in`.zshrc`, onzichtbaar voor de agent). Aan de rechterkant, wat je nu hebt (de essentiële paden in`.zshenv`, overal zichtbaar).

gereedschap Voor (.zshrc alleen) Na (.zshenv + symlink) Zichtbaar door OpenCode?

~/apps(gh, vscode)

PATH niet geëxporteerd in .zshrc

`$HOME/apps`in .zshenv

✅

SDKMAN Java

`sdkman-init.sh`gebronst in .zshrc

`$HOME/.sdkman/candidates/java/current/bin`in .zshenv

✅

SDKMAN Gradle

`sdkman-init.sh`gebaseerd op .zshrc

`$HOME/.sdkman/candidates/gradle/current/bin`in .zshenv

✅

NVM Node

`nvm use --lts`in .zshrc

`$HOME/.nvm/current/bin`in .zshenv (symlink)

(Empty)

pnpm

`$PNPM_HOME`in .zshrc

`$PNPM_HOME`in .zshenv

✅

JetBrains Toolbox

Automatisch toegevoegd door Toolbox in .zshrc

`$HOME/.local/share/JetBrains/Toolbox/scripts`in .zshenv

[The translated text is empty, as no French text was provided to translate.]

Python 3

`/usr/bin/python3`in PATH .zshrc

Expliciet in .zshenv

✅

Valkuilen en mitigaties

Niet alles is perfect met deze aanpak. Dit zijn de problemen die ik heb tegengekomen, en hoe je ze kunt omzeilen.

val Beschrijving Mitigatie

gedupliceerde PATH

Als .zshenv en .zshrc dezelfde pad toevoegen, verschijnt het twee keer

`.zshenv`is afkomstigvoor.zshrc. Beide worden gelezen in een interactieve shell. PATH kan dupliceren. Het is cosmetisch, niet functioneel. Om dit te voorkomen: plaats alleen die paden in .zshenv die afwezig zijn in de standaard PATH.

NVM: hardcoded versie in .zshenv

zetten`~/.nvm/versions/node/v22.19.0/bin`hardgecodeerd wordt onwaar bij het volgende`nvm install`

Gebruik de symlink`$HOME/.nvm/current/bin`+ de wrapper`_nvm`in .zshrc

SDKMAN : source sdkman-init.sh in .zshenv

Langzaam, fragiel, nutteloos in een niet-interactieve shell

Gebruik de symlinks`candidates/<tool>/current/bin`direct

Ontbrekende alias na wijziging

De wrapper`nvm='_nvm'`in .zshrc heeft pas effect na herladen

Herstart de shell of`source ~/.zshrc`

OpenCode ziet de wijzigingen niet

De agent heeft al zijn subshells gelanceerd met het oude .zshenv

Herstart OpenCode na wijziging van .zshenv

.zshenv te zwaar

Plaats zware functies of interactieve initialisaties in .zshenv

.zshenv = omgevingsvariabelen + PATH alleen. Geen`source`, geen zware functies, geen prompts.

Geleerde lessen

  1. .zshrc` is interactief, .zshenv is universeel— Als een variabele in alle shells (AI-agents, cron, scripts, IDE) moet bestaan, gaat deze naar`.zshenv`. De woonkamer is comfortabel, maar de entreehal is de enige plek waar iedereen passeert.

  2. Versiebeheerders zijn niet gelijk— SDKMAN maakt symlinks`current`per ontwerp. NVM niet. Je moet dit tekort handmatig opvullen. Dit is een belangrijke les: voordat je je PATH configureert, controleer of je pakketbeheerder een vast ankerpunt biedt.

  3. Bron de init-scripts niet in .zshenv—sdkman-init.sh et `nvm.sh`zijn ontworpen voor een interactieve shell. Ze zijn traag en breekbaar in een niet-interactieve context. De symlinks`current`zijn voldoende en zijn onmiddellijk

  4. Altijd testen in een niet-interactieve shell—`zsh -c 'echo $PATH'`simuleert exact wat een agent ziet. Dit is de validatietest. Zonder deze test weet je niet of je configuratie werkt voor de agents.

  5. Het wrapper-patroon is herbruikbaar— Hetzelfde patroon`_nvm`+`alias nvm='_nvm'`Is van toepassing op elke tool die de PATH dynamisch wijzigt zonder een stabiele spoor achter te laten. Het is een extra tool in jouw ideeënbak.

@startuml
skinparam backgroundColor #FEFEFE

rectangle "VOOR" as avant {
  card "Interactieve shell: ✅\nNiet-interactieve shell: ❌\nOpenCode: ❌\nCron: ❌\nScripts: ❌" as av1 #FDEDEC
}

rectangle "NA" as apres {
  card "Interactieve shell: ✅\nNiet-interactieve shell: ✅\nOpenCode: ✅\nCron: ✅\nScripts: ✅" as ap1 #E8F8E8
}

avant --> apres : .zshenv +\nsymlink ~/.nvm/current

@enduml

Eindcontrole

Na het maken`.zshenv`en de NVM symlink, controleer of alles werkt — in de twee contexten:

# Shell interactif (votre terminal)
gh --version && java -version && node --version
# Shell non-interactif (simulation OpenCode)
zsh -c 'gh --version && java -version && node --version'

Beide moeten slagen. Als ja, ziet uw IA-agent dezelfde tools als u. Als nee, ga terug naar de stap-voor-stapdiagnose — u bent waarschijnlijk een pad vergeten of de NVM-symlink is niet up-to-date.

Automatiseer deze test.Voeg deze controle toe in een healthcheck-script dat u uitvoert na elke update van uw tools. Een`zsh -c 'which java && which node && which gh'`in CI, het is een waarborg tegen verrassingen

Hallo `world Een niet-interactieve shell is als een stille gast: hij leest alleen wat op de voordeur wordt weergegeven. Als de PATH in de woonkamer staat, zal hij het nooit zien. __

Officiële documentatie

Genoemde tools

Verder gaan

Gerelateerde artikelen