tempo di lettura : 13 minutes

Avvii OpenCode nel tuo terminale, tutto funziona. L’agente tenta un`gh`— introvabile. Un`java -version`— assente. Un`node`— da nessuna parte. Questi strumenti sono proprio lì nel vostro shell. Il problema ? OpenCode lancia dei shellnon interattiviche non leggono mai il tuo`.zshrc`. Ecco come diagnosticare e correggere correttamente.

La scena: un agente cieco in un mondo di strumenti

Era un martedì sera. Avevo appena installatohttps://opencode.ai[OpenCode], l’agente IA che prometteva di trasformare il mio modo di codificare. Primo test: chiedergli di elencare i miei repository GitHub.

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

Strano.`gh`funzionava perfettamente nel mio terminale. Sto provando altro :

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

Poi :

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

Java, Gradle, Node,gh— tutto era invisibile per l’agente. Il mio terminale, invece, vedeva tutto. Come se l’agente e io vivessimo in due mondi paralleli.

Ho messo ore a capire. Ore di`echo $PATH`, de which java, di frustrazione crescente. Ho finito per capire che il problema non erano gli strumenti — era ilguscio. OpenCode, come qualsiasi agente che avvia sottoprocessi, lavora nei shellnon interattivi. E Zsh, in questi shell, ignora semplicemente il tuo`.zshrc`.

Quello che segue è il resoconto completo di questa diagnosi, della risoluzione, e della lezione che ho tirata. Se utilizzi un agente IA — OpenCode, Aider, Cursor, o anche degli script cron — questo problema ti riguarderà un giorno.

Anatomia del bug: due shell, due mondi

Quando apri un terminale, Zsh lo tratta come un shellinterattivo. Carica`.zshrc`, che inizializza tutto: SDKMAN, NVM, pnpm, gli alias, il tuo bel prompt. Il tuo ambiente è completo.

Ma quando OpenCode esegue un comando, non avvia un terminale interattivo. Avvia una shellnon interattivo— un shell di attività, senza umano dietro lo schermo. E Zsh, in questo contesto, salta`.zshrc`. Legge solo`.zshenv`.

Perché esiste questa distinzione? Perché una shell non interattiva è progettata per eseguire script, non per servire un utente umano. Caricare gli alias, il prompt e le completazioni in uno script che gira in background sarebbe uno spreco. Il problema è che il tuo PATH — l’informazione più critica per trovare gli eseguibili — viene spesso inizializzato in`.zshrc`, non in`.zshenv`.

@startuml
skinparam backgroundColor #FEFEFE

actor "Tu" as user
actor "OpenCode\n(agent)" as agent
participant "Shell\ninterattivo\n(.zshrc caricato)" as ishell
participant "Shell\nnon interattivo\n(.zshrc ignorato)" as nshell

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

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

note right of nshell
.zshrc ignorato !
PATH = /usr/bin:/bin
+ alcune directory predefinite
end note

@enduml

La radice del problema :Zsh esegue il source di .zshrc solo per le shell interattive. OpenCode, come ogni agente IA che lancia sottoprocessi, utilizza shell non interattivi. Questi shell leggono solo`.zshenv`.

I colpevoli : .zshrc vs .zshenv

Zsh dispone diquattro file di inizializzazione, chacun avec un rôle précis. C’est un design élégant — mais c’est aussi le nœud du problème :

File sourcing quando Ruolo Modifica il PATH ?

.zshenv

Sempre(interattivo + non interattivo + login)

Variabili d’ambiente essenziali, PATH

✅ Sì — è il posto di SA

.zprofile

Solo le shell di login

Comandi lenti (una volta per session)

Possibile

.zshrc

Solo shell interattive

Alias, prompt, completamento, strumenti interattivi

❌ Non per le variabili critiche

.zlogin

Shells di login (dopo .zshrc)

Messaggi di benvenuto, finalizzazione

raramente

La tabella fornisce un indizio, ma bisogna comprenderlo in profondità. Pensaci come le stanze di una casa :

  • `.zshenv`è ilatrio d’ingresso— tutti passano di lì, visitatori o residenti. Se mettete qualcosa qui, ogni shell potrà vederlo.

  • `.zshrc`è ilsoggiorno— solo i residenti (shell interattivi) vi entrano. I visitatori (shell non interattivi) rimangono nella hall.

  • .zprofile et `.zlogin`sono pezzi specializzati per le shell di login (come quando ti connetti via SSH).

Il dramma è che la maggior parte di noi mette il PATH nel salotto. E l’agente IA, lui, non ha mai il diritto di entrarci.

Il problema in un diagramma :

@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

Quando apri un terminale, Zsh è interattivo: legge`.zshrc`, tutto funziona. Quando OpenCode avvia un shell per eseguire un comando, Zsh è non interattivo: salta`.zshrc`, legge solo`.zshenv`. Et si `.zshenv`non esiste o non contiene il PATH — è il deserto.

Perché Zsh lo fa?È una scelta di progettazione ereditata da Unix. Un shell non interattivo deve essereveloce et riproducibile. Caricare gli alias, i prompt colorati e le inizializzazioni pesanti di SDKMAN in uno script cron o in un agente IA sarebbe lento e fragile. La separazione è quindi logica :`.zshenv`per l’essenziale (variables, PATH)`.zshrc`per il comfort (aliases, prompt, completiamo). Il problema si verifica quando si mette l’essenziale nel comfort.

Diagnostica passo a passo

Prima di correggere, è necessario capire esattamente cosa manca. Ecco un metodo diagnostico riproducibile — tienilo nei preferiti se lavori con agenti IA.

Verificare il PATH dell’agente

Si confronta ciò che vede il terminale interattivo con ciò che vede una shell non interattiva — ovvero ciò che vede OpenCode.

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

Risultato tipico:

/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

Ora, simuliamo ciò che vede l’agente — una shell non interattiva :

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

Risultato :

/home/cheroliv/.local/bin

Cinque percorsi su sei sono scomparsi.L’agente è amputato dell'83% del suo ambiente. È come se ti chiedessero di cucinare senza la metà dei tuoi utensili — potresti far bollire dell’acqua, ma nient’altro.

Il comando`zsh -c 'echo $PATH'`è tuostrumento di diagnostico numero 1. Se uno strumento che usi quotidianamente è assente dal risultato, il tuo agente IA non potrà vederlo neanche. Provala prima e dopo ogni modifica di`.zshenv`.

2. Identificare ciò che è in .zshrc ma non è in .zshenv

Ora stiamo cercando il colpevole in`.zshrc`. Si filtrano le righe che menzionano i nostri strumenti:

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

Si trova tipicamente:

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"

Tutto ciò èinvisibileper OpenCode. E il`PATH`della riga 119 non è nemmeno`export`Non lascia mai la shell corrente. È un dettaglio tecnico cruciale: una variabile senza`export`rimane locale alla shell che la definisce. I sottoshell — come quelli avviati da OpenCode — non la ereditano mai.

3. Verificare se .zshenv esiste

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

Se la risposta è « FICHIER ABSENT », è lì che si decide tutto.

La soluzione : .zshenv + i percorsi corretti

La strategia è semplice, ma richiede precisione: inserire in`.zshenv` soloi percorsi essenziali, senza caricare gli script di inizializzazione pesanti. Non spostiamo`.zshrc`in`.zshenv`— si estrae l’essenziale.

Creare .zshenv con tutti i percorsi essenziali

`.zshenv`è ilsolo fileche Zsh garantisce di eseguire lo source intuttii contesti. È lì che deve andare il 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"

Si usa`$HOME/.nvm/current/bin`e non`$HOME/.nvm/versions/node/v22.19.0/bin`. La ragione: le versioni di Node cambiano. Un percorso hardcoded diventa falso al prossimo`nvm install`. Ulteriori dettagli nella sezione successiva.

Perché non source sdkman-init.sh in .zshenv ?

L’approccio più allettante sarebbe semplicemente di riprodurre in`.zshenv`quello che facciamo in`.zshrc`— source gli script di inizializzazione. Sipotrebbeessere tentato di fare :

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

Problemi :

  • Lento(empty)`sdkman-init.sh`effettua risoluzioni di rete e verifiche a ogni shell. In una shell non interattiva, è un costo inutile.

  • Fragile: SDKMAN si aspetta un contesto interattivo. La sua inizializzazione può fallire silenziosamente in una pipe o in un sotto-shell.

  • Inutile: SDKMAN colloca i suoi candidati in`~/.sdkman/candidates/<tool>/current/bin`— collegamenti simbolici stabili che puntano alla versione attiva. Possiamo usarlidirettamente.

L’approccio corretto : aggirare l’inizializzazione di SDKMAN e puntare direttamente sui symlink`current`. È la chiave di tutta la soluzione :utilizzare la struttura dei file come contratto, anziché il codice di inizializzazione.

@startuml
skinparam backgroundColor #FEFEFE

rectangle "Approche LENTE\n(source sdkman-init.sh)" as slow {
  card ".zshenv source sdkman-init.sh\n2-3 secondes par shell\nVérifications réseau\nRisque d'échec" as s1 #FDEDEC
}

rectangle "Approche RAPIDE\n(symlinks directs)" as fast {
  card ".zshenv export PATH=...current/bin\n0 ms\nPas de réseau\nPas d'initialisation" as s2 #E8F8E8
}

slow --> fast : Même résultat final\nLe PATH pointe sur current/bin\ndans les deux cas

@enduml

Il caso NVM: quando il gestore di versioni dimentica di lasciare una traccia

Il problema NVM

È qui che l’indagine mi ha portato più lontano. SDKMAN ha un design elegante: quando si fa`sdk install java 25.0.2-tem`, crea un symlink:

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

Questo collegamento simbolico èsempre aggiornato. Indicarlo dentro`.zshenv`e sei coperto, qualunque sia la shell. Beautiful.

NVM, lui, non creanientedi tale. Nessun symlink`current`. Nessun punto di ancoraggio stabile. Dobbiamo puntare su un percorso versionato fisso, che sembra così:

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

al prossimo`nvm install 24`, questo sentiero è morto. Il tuo`.zshenv`punta verso una versione che non è più la versione attiva. È una bomba a orologeria.

Perché NVM fa così ?NVM funziona modificando dinamicamente il PATH ad ogni`nvm use`. È progettato per sviluppatori che cambiano versione spesso, in una shell interattiva. Il symlink`current`non era nelle specifiche iniziali — è un’omissione di progettazione che correggeremo noi stessi.

@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 ❌ (prima della correzione)" 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
Nessun symlink current!
.zshenv deve puntare in modo statico
Si romperà al prossimo nvm install
  end note
}

package "NVM ✅ (dopo la correzione)" 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

Poiché NVM non lo fa, lo facciamo noi stessi. Il principio è lo stesso di SDKMAN — un symlink`current`che punta sempre alla versione attiva. Lo creiamo una volta, poi lo automatizziamo affinché si aggiorni da solo.

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

Adesso, in`.zshenv`, si utilizza :

$HOME/.nvm/current/bin

piuttosto che :

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

Un symlink non vale nulla se non si aggiorna. Il symlink`current`deve aggiornarsi quando facciamo`nvm use` ou nvm install. La soluzione : uninvolucro— una funzione che avvolge il vero comando`nvm`e aggiorna il link simbolico dopo ogni chiamata.

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

Come funziona, nel dettaglio :

@startuml
skinparam backgroundColor #FEFEFE

actor Développeur
participant "wrapper nvm\n(_nvm)" as wrapper
participant "nvm reale\n(comando 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
Sempre aggiornato!
Utilizzato da `.zshenv`
→ accessibile tramite OpenCode
end note

@enduml

Il wrapper`_nvm`chiama il vero comando`nvm`, poi aggiorna il collegamento simbolico. L’alias`nvm='_nvm'`fa in modo che digitando`nvm`, si passa attraverso il wrapper. E all’inizializzazione della shell, facciamo lo stesso dopo`nvm use --lts`.

`nvm_version_path`è una funzione interna di NVM che risolve il percorso completo di una versione. Evita di ricostruire manualmente il percorso.

Tabella riassuntiva dei percorsi

Prima di passare alle trappole, un riepilogo visivo della trasformazione. A sinistra, ciò che avevi (tutto nel`.zshrc`, invisibile per l’agente). A destra, ciò che hai ora (i percorsi essenziali in`.zshenv`, visibili ovunque).

Strumento Prima (.zshrc solo) Dopo (.zshenv + symlink) Visibile da OpenCode ?

~/apps(gh, vscode)

PATH non esportato in .zshrc

`$HOME/apps`in .zshenv

✅

SDKMAN Java

`sdkman-init.sh`source in .zshrc

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

✅

SDKMAN Gradle

`sdkman-init.sh`sorgente in .zshrc

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

✅

Nodo NVM

`nvm use --lts`in .zshrc

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

✅

pnpm

`$PNPM_HOME`nel .zshrc

`$PNPM_HOME`in .zshenv

✅

JetBrains Toolbox

Aggiunto automaticamente da Toolbox in .zshrc

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

✅

Python 3

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

Esplicitamente in .zshenv

✅

Trappole e mitigazioni

Non tutto è perfetto con questo approccio. Ecco i problemi che ho incontrato, e come aggirarli.

trappola Descrizione Mitigazione

PATH duplicato

Se .zshenv e .zshrc aggiungono lo stesso percorso, appare due volte

`.zshenv`è ottenutoprima.zshrc. Entrambi vengono letti in una shell interattiva. Il PATH può essere duplicato. È solo estetico, non funzionale. Per evitarlo: mettere in .zshenv solo i percorsi assenti dal PATH predefinito.

NVM : versione hardcoded in .zshenv

Mettere`~/.nvm/versions/node/v22.19.0/bin`in duro diventa falso al prossimo`nvm install`

Utilizzare il symlink`$HOME/.nvm/current/bin`+ il wrapper`_nvm`in .zshrc

SDKMAN : source sdkman-init.sh in .zshenv

Lento, fragile, inutile in una shell non interattiva

Usare i symlinks`candidates/<tool>/current/bin`direttamente

Alias mancante dopo la modifica

Il wrapper`nvm='_nvm'`nel .zshrc ha effetto solo dopo il ricaricamento

Riavvia la shell o`source ~/.zshrc`

OpenCode non vede le modifiche

L’agente ha già lanciato i suoi sottoshell con il vecchio .zshenv

Riavviare OpenCode dopo la modifica di .zshenv

.zshenv troppo carico

Mettere funzioni pesanti o inizializzazioni interattive in .zshenv

.zshenv = variabili d’ambiente + solo PATH. Non`source`, nessuna funzione pesante, nessun prompt.

Lezioni apprese

  1. .zshrc` è interattivo, .zshenv è universale— Se una variabile deve esistere in tutti i shell (agenti IA, cron, script, IDE), essa va in`.zshenv`. Il soggiorno è confortevole, ma l’ingresso è l’unico posto dove passa tutto il mondo.

  2. I gestori di versioni non sono uguali— SDKMAN crea symlink`current`per design. NVM no. Bisogna colmare questa lacuna manualmente. È una lezione importante: prima di configurare il tuo PATH, verifica se il tuo gestore offre un punto di ancoraggio stabile.

  3. Non caricare gli script di init in .zshenv—sdkman-init.sh et `nvm.sh`Sono progettati per una shell interattiva. Sono lenti e fragili in un contesto non interattivo. I symlink`current`sono sufficienti e sono istantanei.

  4. Testare sempre in shell non interattiva—`zsh -c 'echo $PATH'`Simula esattamente ciò che vede un agente. È il test di validazione. Senza questo test, non sai se la tua configurazione funziona per gli agenti.

  5. Il wrapper pattern è riutilizzabile— Lo stesso pattern`_nvm`(empty)`alias nvm='_nvm'`Si applica a qualsiasi strumento che modifica il PATH dinamicamente senza lasciare una traccia stabile. È uno strumento in più nella tua cassetta delle idee.

@startuml
skinparam backgroundColor #FEFEFE

rectangle "PRIMA" as avant {
  card "Shell interattiva: ✅\nShell non interattiva: ❌\nOpenCode: ❌\nCron: ❌\nScript: ❌" as av1 #FDEDEC
}

rectangle "DOPO" as apres {
  card "Shell interattiva: ✅\nShell non interattiva: ✅\nOpenCode: ✅\nCron: ✅\nScript: ✅" as ap1 #E8F8E8
}

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

@enduml

Verifica finale

Dopo aver creato`.zshenv`e il symlink NVM, verifica che tutto funzioni — in entrambi i contesti :

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

Entrambi devono riuscire. Se sì, il tuo agente IA vedrà gli stessi strumenti che tu. Se no, torna alla diagnosi passo a passo — probabilmente hai dimenticato un percorso o il symlink NVM non è aggiornato.

Automatizza questo test.Aggiungi questa verifica in uno script di healthcheck che esegui dopo ogni aggiornamento dei tuoi strumenti. Un`zsh -c 'which java && which node && which gh'`in CI, è un’assicurazione contro le sorprese.

_ Una shell non interattiva è come un ospite silenzioso: legge solo ciò che è visualizzato sulla porta d’ingresso. Se il PATH si trova nel soggiorno, non lo vedrà mai. _

documentazione ufficiale

Strumenti menzionati

Per andare più lontano

Articoli correlati