Skip to content

Commit 14d24a6

Browse files
committed
Blog: Why claude code can't find your tools
Signed-off-by: Yi Nuo <218099172+yi-nuo426@users.noreply.github.com>
1 parent a95b9f6 commit 14d24a6

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

9.07 MB
Loading
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
---
2+
title: "Why Claude Code Can't Find Your Tools"
3+
subtitle: "Understanding non-interactive shells and the .zshenv fix"
4+
date: 2026-03-09 10:00:00 -0530
5+
author: Layer5 Team
6+
thumbnail: ./claude-code-shell-path.png
7+
darkthumbnail: ./claude-code-shell-path.png
8+
description: "Claude Code runs in a non-interactive shell that never sources .zshrc. Learn why tools vanish and how moving PATH exports to .zshenv fixes it for good."
9+
type: Blog
10+
category: Engineering
11+
tags:
12+
- Engineering
13+
- ai
14+
- Open Source
15+
featured: false
16+
published: true
17+
resource: true
18+
---
19+
20+
{/* TODO: Add thumbnail image at ./claude-code-shell-path.png */}
21+
22+
import { BlogWrapper } from "../../Blog.style.js";
23+
import { Link } from "gatsby";
24+
import Blockquote from "../../../../reusecore/Blockquote";
25+
import CTA_FullWidth from "../../../../components/Call-To-Actions/CTA_FullWidth";
26+
import CTAImg from "../../../../assets/images/layer5/5 icon/png/light/5-light-no-trim.webp";
27+
28+
<BlogWrapper>
29+
30+
<div class="intro">
31+
<p>
32+
You type <code>gh pr list</code> in your terminal and it works perfectly. You ask Claude Code to
33+
do the same, and it replies: <em>"command not found: gh"</em>. Your Go toolchain, nvm, pyenv,
34+
Homebrew binaries — all invisible to the agent. This is not a bug in Claude Code. It is a
35+
fundamental property of how Unix shells start up, and once you understand it, the fix is a
36+
one-time five-minute change.
37+
</p>
38+
</div>
39+
40+
## The Surprise
41+
42+
AI coding assistants like Claude Code, Cline, Aider, and similar tools spawn child shell processes
43+
to run commands on your behalf. From where you sit, the terminal looks identical to the one you use
44+
every day. But the shell those tools launch is fundamentally different from the one you interact
45+
with — and that difference determines which startup files are read, which means it determines what
46+
is on your PATH.
47+
48+
The result is a confusing experience: tools you have used for years are suddenly invisible to the
49+
agent trying to help you. The culprit is not your installation. It is the shell startup file
50+
hierarchy.
51+
52+
## Zsh Startup Files and When They Are Sourced
53+
54+
Zsh loads different configuration files depending on how it was launched. There are four main files,
55+
and they are read in this order:
56+
57+
| File | Login shell | Interactive shell | Non-interactive shell |
58+
|---|---|---|---|
59+
| `~/.zshenv` | Yes | Yes | Yes |
60+
| `~/.zprofile` | Yes | No | No |
61+
| `~/.zshrc` | No | Yes | No |
62+
| `~/.zlogin` | Yes | No | No |
63+
64+
The key column is the last one. A **non-interactive, non-login shell** — the kind that Claude Code
65+
spawns — sources **only** `~/.zshenv`. Everything else is skipped entirely.
66+
67+
- **`~/.zshenv`** is sourced for every zsh invocation, no matter what. It is the right place for
68+
environment variables that must be available universally: `PATH`, `GOPATH`, `JAVA_HOME`, and
69+
similar exports.
70+
- **`~/.zprofile`** is sourced for login shells (e.g., when you open a new terminal window or SSH
71+
into a machine). Homebrew places its environment setup here on Apple Silicon Macs
72+
(`/opt/homebrew/bin/brew shellenv`), which is why Homebrew tools can also go missing.
73+
- **`~/.zshrc`** is sourced only for interactive shells — sessions where you type commands. This is
74+
where most developers put everything: aliases, prompt configuration, `nvm`, `pyenv`, `rbenv`,
75+
completions, and — critically — PATH customizations.
76+
- **`~/.zlogin`** is sourced after `~/.zshrc` for login shells. It is rarely used by developers
77+
directly.
78+
79+
<Blockquote
80+
quote="A non-interactive, non-login shell sources only ~/.zshenv. Everything in ~/.zshrc is invisible to it — including every PATH export most developers have ever written."
81+
person="Zsh Documentation"
82+
title="Shell Startup File Hierarchy"
83+
/>
84+
85+
## What PATH a Non-Interactive Shell Actually Sees
86+
87+
You can observe this yourself. Run these two commands and compare the output:
88+
89+
```bash
90+
# What a non-interactive shell sees
91+
zsh -c 'echo $PATH'
92+
93+
# What your interactive shell sees
94+
echo $PATH
95+
```
96+
97+
On a typical macOS developer machine, the non-interactive shell might produce something like:
98+
99+
```
100+
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
101+
```
102+
103+
While your interactive shell shows something far richer:
104+
105+
```
106+
/opt/homebrew/bin:/opt/homebrew/sbin:/Users/you/.nvm/versions/node/v20.11.0/bin:
107+
/Users/you/go/bin:/Users/you/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
108+
```
109+
110+
All those extra paths — Homebrew, nvm, Go binaries — live in `~/.zshrc`, `~/.zprofile`, or in
111+
scripts those files source. A non-interactive shell never reads any of them.
112+
113+
## Diagnosing the Problem
114+
115+
Before changing anything, confirm the symptom. Use `zsh -c` to simulate what Claude Code sees:
116+
117+
```bash
118+
# Does Claude Code's shell see the tool?
119+
zsh -c 'which gh'
120+
zsh -c 'which go'
121+
zsh -c 'which node'
122+
123+
# Does your interactive shell see it?
124+
zsh -i -c 'which gh'
125+
zsh -i -c 'which go'
126+
zsh -i -c 'which node'
127+
```
128+
129+
The `-i` flag forces an interactive shell, which sources `~/.zshrc`. If `zsh -c 'which gh'` prints
130+
`gh not found` but `zsh -i -c 'which gh'` prints the correct path, your PATH export is in
131+
`~/.zshrc` and you have confirmed the root cause.
132+
133+
<div class="note">
134+
<strong>Quick diagnosis:</strong> Run <code>zsh -c 'which gh'</code> (no <code>-i</code> flag).
135+
If this fails but <code>which gh</code> in your normal terminal works, your PATH is only set in
136+
<code>~/.zshrc</code>. Move the relevant exports to <code>~/.zshenv</code> to fix it.
137+
</div>
138+
139+
## The Fix: Move PATH Exports to ~/.zshenv
140+
141+
The solution is straightforward: any environment variable that must be visible to all processes —
142+
including non-interactive subshells — belongs in `~/.zshenv`, not `~/.zshrc`.
143+
144+
Open (or create) `~/.zshenv` and add your PATH exports there:
145+
146+
```bash
147+
# ~/.zshenv
148+
# Sourced for every zsh invocation — interactive, login, and non-interactive alike
149+
150+
# Homebrew (Apple Silicon)
151+
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
152+
153+
# Go
154+
export GOPATH="$HOME/go"
155+
export PATH="$GOPATH/bin:$PATH"
156+
157+
# Local binaries
158+
export PATH="$HOME/.local/bin:$PATH"
159+
```
160+
161+
For tools that inject themselves via an eval expression in `~/.zshrc` — such as nvm, pyenv, or
162+
rbenv — you need to move or duplicate that initialization into `~/.zshenv` as well:
163+
164+
```bash
165+
# ~/.zshenv — nvm initialization for non-interactive shells
166+
export NVM_DIR="$HOME/.nvm"
167+
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" --no-use
168+
169+
# pyenv
170+
export PYENV_ROOT="$HOME/.pyenv"
171+
export PATH="$PYENV_ROOT/bin:$PATH"
172+
eval "$(pyenv init --path)"
173+
```
174+
175+
Note the `--no-use` flag for nvm: it initializes nvm without switching to the default Node.js
176+
version, which speeds up shell startup for non-interactive contexts. Remove it if you want the
177+
default version active everywhere.
178+
179+
After saving `~/.zshenv`, verify without restarting your terminal:
180+
181+
```bash
182+
# Reload .zshenv in your current shell
183+
source ~/.zshenv
184+
185+
# Confirm Claude Code's shell type now finds the tool
186+
zsh -c 'which gh'
187+
zsh -c 'which go'
188+
zsh -c 'which node'
189+
```
190+
191+
## What to Keep in .zshrc vs .zshenv
192+
193+
Moving everything to `~/.zshenv` is not the right answer. Some configuration should stay in
194+
`~/.zshrc` because it only makes sense in interactive contexts or because it has side effects
195+
that slow down non-interactive shells unnecessarily.
196+
197+
<div class="tip">
198+
<strong>Guiding principle:</strong> If it is an environment variable that a program needs to
199+
find another program, it belongs in <code>~/.zshenv</code>. If it is a user-facing customization
200+
for your interactive terminal experience, it belongs in <code>~/.zshrc</code>.
201+
202+
**Keep in `~/.zshenv`:**
203+
- `PATH` exports and modifications
204+
- `GOPATH`, `JAVA_HOME`, `PYTHONPATH`, `CARGO_HOME`, and similar tool-specific env vars
205+
- `NVM_DIR`, `PYENV_ROOT`, `RBENV_ROOT` and their `PATH` injections
206+
- `EDITOR`, `PAGER`, `LANG`, `LC_ALL`
207+
208+
**Keep in `~/.zshrc`:**
209+
- Shell aliases (`alias ll='ls -la'`)
210+
- Prompt configuration (Starship, Powerlevel10k, oh-my-zsh)
211+
- Tab completion setup
212+
- Shell functions for interactive use
213+
- History settings
214+
- `zsh` plugins and plugin managers
215+
- Anything that prints output (welcome messages, `neofetch`, etc.)
216+
</div>
217+
218+
## The Broader Pattern: Any Tool That Spawns Subshells
219+
220+
Claude Code is not unique here. This same behavior affects any process that spawns a child shell
221+
without the `-i` or `-l` flags:
222+
223+
- **CI/CD pipelines** (GitHub Actions, GitLab CI) run commands in non-interactive shells. This is
224+
why you often see pipelines that explicitly `source ~/.bashrc` or set up PATH at the top of
225+
every job.
226+
- **Cron jobs** run in minimal environments with almost no PATH set.
227+
- **VS Code integrated terminal tasks** and `launch.json` configurations may use non-interactive
228+
shells depending on the operating system and configuration.
229+
- **SSH remote command execution** (`ssh host 'command'`) uses a non-interactive shell unless you
230+
pass `-t` to force a TTY.
231+
- **Make** and other build systems that shell out to run commands.
232+
- **Docker `RUN` instructions** in Dockerfiles.
233+
234+
If you have ever fixed a "works on my machine" problem by adding `export PATH=...` to a CI
235+
configuration or a Dockerfile, you have already solved the same class of problem. The `~/.zshenv`
236+
fix is just the developer workstation equivalent.
237+
238+
<Blockquote
239+
quote="If a command works in your terminal but fails in a script, a CI job, or an AI agent, the first question to ask is: which startup files does this shell read?"
240+
person="Platform Engineering"
241+
title="Debugging Shell Environment Issues"
242+
/>
243+
244+
## Putting It Together: A Minimal ~/.zshenv Template
245+
246+
Here is a starting point for a `~/.zshenv` that covers the most common developer tools on macOS.
247+
Adjust paths to match your actual installations:
248+
249+
```bash
250+
# ~/.zshenv
251+
# Sourced for ALL zsh shells — interactive, login, and non-interactive.
252+
# Keep this file fast and free of output-producing commands.
253+
254+
# Homebrew (Apple Silicon Mac — change to /usr/local for Intel)
255+
if [[ -x /opt/homebrew/bin/brew ]]; then
256+
eval "$(/opt/homebrew/bin/brew shellenv)"
257+
fi
258+
259+
# Go
260+
export GOPATH="$HOME/go"
261+
export PATH="$GOPATH/bin:$PATH"
262+
263+
# Rust / Cargo
264+
export PATH="$HOME/.cargo/bin:$PATH"
265+
266+
# Local user binaries
267+
export PATH="$HOME/.local/bin:$PATH"
268+
269+
# nvm (initialize without switching to default version)
270+
export NVM_DIR="$HOME/.nvm"
271+
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" --no-use
272+
273+
# pyenv
274+
if command -v pyenv &>/dev/null || [[ -d "$HOME/.pyenv" ]]; then
275+
export PYENV_ROOT="$HOME/.pyenv"
276+
export PATH="$PYENV_ROOT/bin:$PATH"
277+
eval "$(pyenv init --path)"
278+
fi
279+
280+
# Editor and locale
281+
export EDITOR="vim"
282+
export LANG="en_US.UTF-8"
283+
export LC_ALL="en_US.UTF-8"
284+
```
285+
286+
With this in place, restart Claude Code (or any tool that spawns subshells) and run your
287+
verification:
288+
289+
```bash
290+
zsh -c 'which gh && which go && which node'
291+
```
292+
293+
All three should now resolve to their correct paths.
294+
295+
## Summary
296+
297+
The "command not found" error in Claude Code and similar AI coding assistants is a shell startup
298+
file problem, not a tool installation problem. Zsh only sources `~/.zshenv` for non-interactive,
299+
non-login shells. Everything most developers have placed in `~/.zshrc` — including PATH exports,
300+
version manager initializations, and tool-specific environment variables — is invisible to those
301+
shells.
302+
303+
The fix is permanent and simple:
304+
305+
1. Move PATH exports and tool-specific environment variables to `~/.zshenv`.
306+
2. Verify with `zsh -c 'which <tool>'` before and after.
307+
3. Keep interactive customizations (aliases, prompt, completions) in `~/.zshrc`.
308+
309+
The same fix benefits CI pipelines, cron jobs, Makefiles, Docker builds, and any other context
310+
where commands run in a non-interactive shell environment.
311+
312+
---
313+
314+
*Exploring AI-assisted development workflows and developer tooling? The <Link to="/community">Layer5 community</Link> is an active group of platform engineers, open source contributors, and DevOps practitioners. Join us on [Slack](https://slack.layer5.io) to share what you are building and get help when you hit walls like this one. You can also follow the <Link to="/blog">Layer5 blog</Link> for more practical engineering posts.*
315+
316+
<CTA_FullWidth
317+
image={CTAImg}
318+
heading="Join the Layer5 Community"
319+
alt="Layer5 Community"
320+
content="Connect with platform engineers, DevOps practitioners, and open source contributors who are building the future of cloud native infrastructure."
321+
button_text="Join the Community"
322+
url="/community"
323+
external_link={false}
324+
/>
325+
326+
</BlogWrapper>

0 commit comments

Comments
 (0)