tempo de leitura : 13 minutes

Você inicia o OpenCode no seu terminal, tudo funciona. O agente tenta um`gh`— não encontrado. Um`java -version`— ausente. Um`node`— em nenhum lugar. Porém, essas ferramentas estão realmente aqui no votre shell. O problema? OpenCode lança shells.não interativosque nunca leem seu`.zshrc`. Como diagnosticar e corrigir isso corretamente.

toc

[]

A cena: um agente cego num mundo de ferramentas

Era uma terça-feira à noite. Eu acabara de instalarhttps://opencode.ai[OpenCode], o agente IA que prometia transformar a minha forma de codar. Primeiro teste: pedir a ele para listar os meus repositórios do GitHub.

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

Estranho.`gh`funcionava perfeitamente no meu terminal. Estou tentando outra coisa:

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

Então:

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

Java, Gradle, Node,gh— tudo era invisível para o agente. Meu terminal, porém, via tudo. Como se o agente e eu vivêssemos em dois mundos paralelos.

Levei horas para entender. Horas de`echo $PATH`, de which java, de crescente frustração. Finalmente percebi que o problema não eram as ferramentas — era oshell. OpenCode, assim como todo agente que inicia subprocessos, trabalha em shellsnão interativos. E o Zsh, nesses shells, ignora simplesmente seu`.zshrc`.

O que se segue é o relato completo deste diagnóstico, da resolução e da lição que tirei. Se você utiliza um agente de IA — OpenCode, Aider, Cursor ou mesmo scripts cron — esse problema o afetará um dia.

Anatomia do bug: dois shells, dois mundos

Quando você abre um terminal, o Zsh o trata como um shellinterativo. Ele carrega`.zshrc`, que inicializa tudo: SDKMAN, NVM, pnpm, os aliases, o seu prompt bonito. O seu ambiente está completo.

Mas quando o OpenCode executa um comando, ele não inicia um terminal interativo. Ele inicia um shellnão interativo— um shell de tarefa, sem humano atrás da tela. E Zsh, neste contexto, salta`.zshrc`. Ele lê apenas`.zshenv`.

Por que essa distinção existe? Porque um shell não-interativo foi projetado para executar scripts, não para atender a um usuário humano. Carregar os aliases, o prompt e as completions em um script que roda em segundo plano seria um desperdício. O problema é que o seu PATH — a informação mais crítica para localizar os executáveis — costuma ser inicializado em`.zshrc`, não em`.zshenv`.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 5) ]

@startuml
skinparam backgroundColor #FEFEFE

actor "Você" as user
actor "OpenCode
^^^^^
 Syntax Error? (Assumed diagram type: sequence)

@startuml
skinparam backgroundColor #FEFEFE

actor "Você" as user
actor "OpenCode
(agente)" as agent
participant "Shell
interativo
(.zshrc carregado)" as ishell
participant "Shell
não interativo
(.zshrc ignorado)" as nshell

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

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

note right of nshell
  .zshrc ignoré !
  PATH = /usr/bin:/bin
  + quelques répertoires défaut
end note

@enduml

A raiz do problema:Zsh apenas carrega .zshrc para shells interativos. OpenCode, como todo agente de IA que inicia subprocessos, utiliza shells não-interativos. Esses shells leem apenas`.zshenv`.

Os culpados: .zshrc vs .zshenv

Zsh dispõe dequatro arquivos de inicialização, cada um com um papel preciso. É um design elegante — mas isso também é o nó do problema:

arquivo

Obtido quando

papel

Altera o PATH ?

.zshenv

sempre(interativo + não interativo + login)

Variáveis de ambiente essenciais, PATH

✅ Sim — é o lugar dele/ela

.zprofile

Apenas shells de login

Comandos lentos (uma vez por sessão)

Possível

.zshrc

Shells interativos apenas

Alias, prompt, completão, ferramentas interativas

❌ Não para as variáveis críticas

.zlogin

Shells de login (depois .zshrc)

Mensagens de boas-vindas, finalização

raramente

A tabela dá uma indicação, mas é preciso compreendê-la em profundidade. Pense nisso como os cómodos de uma casa:

  • `.zshenv`é ohall de entrada— todos passam por lá, visitante ou residente. Se você colocar algo aqui, qualquer shell poderá vê-lo.

  • `.zshrc`é osala— apenas os residentes (shells interativos) entram ali. Os visitantes (shells não interativos) permanecem no hall.

  • .zprofile et `.zlogin`são peças especializadas para shells de login (como quando você se conecta via SSH).

O drama é que a maioria de nós colocamos o PATH na sala. E o agente de IA, ele, nunca tem o direito de entrar lá.

O problema em um diagrama:

zsh init flow

Quando você abre um terminal, o Zsh é interativo: ele lê`.zshrc`, tudo funciona. Quando o OpenCode lança um shell para executar um comando, o Zsh é não-interativo: ele pula`.zshrc`, só lê`.zshenv`. Et si `.zshenv`não existe ou não contém o PATH — é o deserto.

Por que o Zsh faz isso?É uma escolha de design herdada do Unix. Um shell não interativo deve serrápido et reprodutível. Carregar os aliases, os prompts coloridos e as inicializações pesadas do SDKMAN em um script de cron ou um agente de IA seria lento e frágil. A separação, portanto, é lógica :`.zshenv`para o essencial (variáveis, PATH)`.zshrc`para o conforto (aliases, prompt, completamos). O problema ocorre quando colocamos o essencial no conforto.

Diagnóstico passo a passo

Antes de corrigir, é preciso entender exatamente o que falta. Este é um método de diagnóstico reprodutível — guarde-a nos favoritos se você trabalha com agentes de IA.

Verificar o PATH do agente

Comparamos o que o seu terminal interativo vê com o que um shell não interativo vê — ou seja, o que o OpenCode vê.

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

Resultado típico :

/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

Agora, vamos simular o que o agente vê — um shell não-interativo :

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

Resultado :

/home/cheroliv/.local/bin

Cinco caminhos de seis desapareceram.O agente está amputado de 83% do seu ambiente. É como se você fosse solicitado a cozinhar sem metade dos seus utensílios — você poderia ferver água, mas pouco mais.

Encomenda`zsh -c 'echo $PATH'`é seuferramenta de diagnóstico número 1. Se uma ferramenta que você utiliza diariamente estiver ausente do resultado, seu agente de IA também não poderá vê-la. Teste-a antes e depois de cada modificação de`.zshenv`.

2. Identifique o que está em .zshrc mas não está em `.zshenv

Agora, estamos procurando o culpado em`.zshrc`. Filtrar as linhas que mencionam nossas ferramentas :

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

Costuma-se encontrar :

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"

Tudo isso éinvisívelpara OpenCode. E o`PATH`da linha 119 nem mesmo é`export`é — ele nunca sai do shell atual. É um detalhe técnico crucial: uma variável sem`export`Permanece local ao shell que a define. Os sub-shells — como aqueles lançados pelo OpenCode — nunca a herdam.

3. Verificar se o .zshenv existe

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

Se a resposta é « ARQUIVO AUSENTE », é aí que tudo se decide.

A solução: .zshenv + os caminhos corretos

A estratégia é simples, mas exige precisão: colocar dentro`.zshenv` apenascaminhos essenciais, sem executar os scripts de inicialização pesados. não movemos`.zshrc`em`.zshenv`— extraímos o essencial.

Criar .zshenv com todos os caminhos essenciais

`.zshenv`é oarquivo únicoque Zsh garante de sourcear emtodosos contextos. É aqui que o PATH deve ir.

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"

Utiliza-se`$HOME/.nvm/current/bin`e não`$HOME/.nvm/versions/node/v22.19.0/bin`. A razão : as versões do Node mudam. Um caminho fixo se torna falso no próximo`nvm install`. Mais detalhes na seção seguinte.

Por que não source sdkman-init.sh no .zshenv?

A abordagem mais tentadora seria simplesmente reproduzir em`.zshenv`o que fazemos em`.zshrc`— carregar os scripts de inicialização. Nóspoderiaestar tentado de fazer:

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

Problemas :

  • Lento : `sdkman-init.sh`faz resoluções de rede e verificações a cada shell. Em um shell não interativo, é um custo desnecessário.

  • frágil: SDKMAN espera um contexto interativo. Sua inicialização pode falhar silenciosamente em um pipe ou em um subshell.

  • inútil: SDKMAN coloca seus candidatos em`~/.sdkman/candidates/<tool>/current/bin`— links simbólicos estáveis apontando para a versão ativa. Pode‑se utilizá‑losdiretamente.

A abordagem correta: contornar a inicialização do SDKMAN e apontar diretamente para os symlinks`current`. É a chave de toda a solução :usar a estrutura de arquivos como contrato, em vez do código de inicialização.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 6) ]

@startuml
skinparam backgroundColor #FEFEFE

rectangle "Abordagem Lenta\n(source sdkman-init.sh)" as slow {
  card ".zshenv source sdkman-init.sh
2-3 segundos por shell
^^^^^
 Syntax Error? (Assumed diagram type: activity)

@startuml
skinparam backgroundColor #FEFEFE

rectangle "Abordagem Lenta\n(source sdkman-init.sh)" as slow {
  card ".zshenv source sdkman-init.sh
2-3 segundos por shell
Verificações de rede
Risco de falha" as s1 #FDEDEC
}

rectangle "Abordagem rápida
(symlinks diretos)" as fast {
  card ".zshenv export PATH=...current/bin
0 ms
Sem rede
Sem inicialização" as s2 #E8F8E8
}

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

@enduml

O caso NVM: quando o gerenciador de versões esquece de deixar uma marca

O problema NVM

É aqui que a investigação me levou mais longe. SDKMAN tem um design elegante: quando se faz`sdk install java 25.0.2-tem`, ele cria um symlink :

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

Este link simbólico ésempre atualizadoApontem-no em`.zshenv`e você está coberto, não importa qual seja o shell. Bonito.

NVM, ele, não crianadade tal. Sem symlink`current`. Não há ponto de ancoragem estável. É necessário apontar para um caminho versionado embutido, que se parece com isso :

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

No próximo`nvm install 24`, este caminho está morto. Seu`.zshenv`apontará para uma versão que não é mais a versão ativa. É uma bomba-relógio.

Por que o NVM faz isso?NVM funciona modificando dinamicamente o PATH a cada`nvm use`. Foi projetado para desenvolvedores que mudam de versão frequentemente, em um shell interativo. O symlink`current`Não estava nas especificações iniciais — é um esquecimento de design que vamos corrigir nós mesmos.

nvm vs sdkman

Como o NVM não faz isso, nós o fazemos nós mesmos. O princípio é o mesmo que o SDKMAN — um symlink`current`que sempre aponta para a versão ativa. Cria-se uma vez, então automatiza-se para que se atualize sozinho.

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

Agora, em`.zshenv`, utiliza :

$HOME/.nvm/current/bin

Em vez de :

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

Um symlink não vale nada se não se atualiza. O symlink`current`deve ser atualizado quando fazemos`nvm use` ou nvm install. A solução : umembrulho— uma função que envolve o comando verdadeiro`nvm`e atualiza o symlink após cada chamada

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

Como funciona, em detalhe:

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 5) ]

@startuml
skinparam backgroundColor #FEFEFE

actor Développeur
participant "nvm envolvedor
^^^^^
 Syntax Error? (Assumed diagram type: sequence)

@startuml
skinparam backgroundColor #FEFEFE

actor Développeur
participant "nvm envolvedor
(_nvm)" as wrapper
participant "nvm real\n(command 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
  Toujours à jour !
  Utilisé par .zshenv
  → accessible par OpenCode
end note

@enduml

O wrapper`_nvm`chama o comando verdadeiro`nvm`, então atualiza o symlink. O alias`nvm='_nvm'`certifique-se de que ao digitar`nvm`, passamos pelo wrapper. E na inicialização do shell, fazemos o mesmo depois`nvm use --lts`.

`nvm_version_path`é uma função interna do NVM que resolve o caminho completo de uma versão. Isso evita de reconstruir o caminho manualmente.

Tabela resumo dos caminhos

Antes de passar para as armadilhas, um resumo visual da transformação. À esquerda, o que você tinha (tudo em`.zshrc`, invisível para o agente). à direita, o que você tem agora (os caminhos essenciais em`.zshenv`, visíveis em todo lugar).

ferramenta Antes (.zshrc apenas) Depois (.zshenv + symlink) Visível pelo OpenCode?

~/apps(gh, vscode)

PATH não exportado no .zshrc

`$HOME/apps`em .zshenv

✅

SDKMAN Java

`sdkman-init.sh`carregado no .zshrc

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

(No output, as there is no French text provided to translate)

SDKMAN Gradle

`sdkman-init.sh`carregado no .zshrc

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

�✅

NVM Node

`nvm use --lts`no .zshrc

`$HOME/.nvm/current/bin`em .zshenv (link simbólico)

�✅

pnpm

`$PNPM_HOME`no .zshrc

`$PNPM_HOME`no .zshenv

✅

JetBrains Toolbox

Auto-adicionado pelo Toolbox em .zshrc

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

�✅

Python 3

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

Explícitamente em .zshenv

�✅

Armadilhas e mitigações

Nem tudo é perfeito com essa abordagem. Aqui estão os problemas que encontrei, e como contorná-los.

armadilha descrição mitigação

Caminho duplicado

Se .zshenv e .zshrc adicionam o mesmo caminho, aparece duas vezes

`.zshenv`é fonteantes.zshrc. Os dois são lidos em um shell interativo. O PATH pode duplicar. É estético, não funcional. Para evitá‑lo: coloque apenas no .zshenv os caminhos ausentes do PATH padrão.

NVM : versão fixa no .zshenv

colocar`~/.nvm/versions/node/v22.19.0/bin`hard-coded torna-se falso no próximo`nvm install`

Usar o symlink`$HOME/.nvm/current/bin`+ o wrapper`_nvm`no .zshrc

SDKMAN: source sdkman-init.sh no .zshenv

Lento, frágil, inútil em um shell não interativo

Usar os symlinks`candidates/<tool>/current/bin`diretamente

Alias ausente após modificação

O wrapper`nvm='_nvm'`no .zshrc só tem efeito após recarregar

Reiniciar o shell ou`source ~/.zshrc`

OpenCode não vê as mudanças

O agente já lançou seus subshells com o .zshenv antigo

Reinicie o OpenCode após a modificação de .zshenv

.zshenv demasiado carregado

Colocar funções pesadas ou inicializações interativas no .zshenv

.zshenv = variáveis de ambiente + PATH apenas. Não`source`, sem funções pesadas, sem prompts.

Lições aprendidas

  1. .zshrc` é interativo, .zshenv é universal— Se uma variável deve existir em todos os shells (agentes IA, cron, scripts, IDE), ela vai para`.zshenv`. A sala é confortável, mas o hall de entrada é o único lugar onde todos passam.

  2. Os gerentes de versões não são iguais— SDKMAN cria symlinks`current`por design. NVM não. É preciso preencher essa lacuna manualmente. É uma lição importante: antes de configurar seu PATH, verifique se o gerenciador oferece um ponto de ancoragem estável.

  3. Não source os init scripts em .zshenv—sdkman-init.sh et `nvm.sh`são projetados para um shell interativo. Eles são lentos e frágeis em um contexto não-interativo. Os symlinks`current`são suficientes e são instantâneos

  4. Sempre testar em shell não-interativo—`zsh -c 'echo $PATH'`simula exatamente o que um agente vê. Este é o teste de validação. Sem este teste, você não sabe se sua configuração funciona para os agentes.

  5. O padrão wrapper é reutilizável— O mesmo padrão`_nvm`+`alias nvm='_nvm'`Aplica-se a qualquer ferramenta que modifique o PATH dinamicamente sem deixar um rastro estável. É uma ferramenta a mais na sua caixa de ideias.

Failed to generate image: PlantUML preprocessing failed: [From <input> (line 6) ]

@startuml
skinparam backgroundColor #FEFEFE

rectangle "ANTES" as avant {
  card "Shell interativo : ✅
Shell não interativo : ❌
^^^^^
 Syntax Error? (Assumed diagram type: activity)

@startuml
skinparam backgroundColor #FEFEFE

rectangle "ANTES" as avant {
  card "Shell interativo : ✅
Shell não interativo : ❌
OpenCode : ❌
Cron : ❌
Scripts : ❌" as av1 #FDEDEC
}

rectangle "DEPOIS" as apres {
  card "Shell interativo : ✅\nShell não interativo : ✅\nOpenCode : ✅\nCron : ✅\nScripts : ✅" as ap1 #E8F8E8
}

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

@enduml

Verificação final

Depois de ter criado`.zshenv`e o symlink NVM, verifique que tudo funciona — nos dois contextos :

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

Ambos devem ter sucesso. Se sim, seu agente de IA verá as mesmas ferramentas que você. Se não, volte ao diagnóstico passo a passo — você provavelmente esqueceu um caminho ou o symlink NVM não está atualizado.

Automatize este teste.Adicione esta verificação em um script de healthcheck que você executa após cada atualização das suas ferramentas. Un`zsh -c 'which java && which node && which gh'`Em CI, é uma garantia contra as surpresas.

</think> (No text provided to translate; output remains empty.) Um shell não-interativo é como um convidado silencioso: ele lê apenas o que está exibido na porta de entrada. Se o PATH estiver na sala de estar, ele nunca o verá. __

Documentação oficial

Ferramentas mencionadas

Para ir além

Articles connexes