<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>~iany/ Environment Variables</title><link>https://blog.iany.me/tags/environment-variables/</link><description>Recent content in Environment Variables «~iany/»</description><language>en-US</language><managingEditor>me@iany.me (Ian Yang)</managingEditor><webMaster>me@iany.me (Ian Yang)</webMaster><copyright>CC-BY-SA 4.0</copyright><lastBuildDate>Wed, 01 Jul 2026 21:27:36 +0800</lastBuildDate><atom:link href="https://blog.iany.me/tags/environment-variables/index.xml" rel="self" type="application/rss+xml"/><item><title>Fix Claude Desktop PATH with CLAUDE_ENV_FILE</title><link>https://blog.iany.me/2026/07/fix-claude-desktop-path-with-claude-env-file/</link><pubDate>Wed, 01 Jul 2026 21:27:36 +0800</pubDate><author>me@iany.me (Ian Yang)</author><guid>https://blog.iany.me/2026/07/fix-claude-desktop-path-with-claude-env-file/</guid><description>&lt;p&gt;Claude Code runs tools and shell commands on your behalf. Launched from a terminal, the CLI inherits your shell environment and everything just works. But under &lt;strong&gt;Claude Desktop&lt;/strong&gt;, Claude Code is spawned from a GUI/desktop session that never sourced your interactive shell config—so if you rely on version managers like mise, asdf, or nix, the tools they install may not be on Claude&amp;rsquo;s &lt;code&gt;PATH&lt;/code&gt;. The fix is a single setting: &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="how-it-works"&gt;How it works&lt;/h2&gt;
&lt;p&gt;Set &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; in &lt;code&gt;.claude/settings.local.json&lt;/code&gt; to point at a shell file. Claude sources it before running any command.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-json"&gt;{
&amp;quot;env&amp;quot;: {
&amp;quot;CLAUDE_ENV_FILE&amp;quot;: &amp;quot;.claude/mise.env&amp;quot;
}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That file is a plain shell snippet—&lt;code&gt;export&lt;/code&gt; statements, one per line:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;export PATH=&amp;quot;/home/ian/.local/share/mise/shims:/home/ian/project/node_modules/.bin:$PATH&amp;quot;
export DATABASE_URL=&amp;quot;postgres://localhost/db&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The path can be relative—it&amp;rsquo;s resolved from the project root—so &lt;code&gt;.claude/mise.env&lt;/code&gt; works on any machine and can be committed alongside the project. Write both, and the next Claude session picks up everything.&lt;/p&gt;
&lt;h2 id="why-not-just-put-path-in-settingsjson"&gt;Why not just put PATH in settings.json?&lt;/h2&gt;
&lt;p&gt;You can set &lt;code&gt;env.PATH&lt;/code&gt; directly in &lt;code&gt;settings.local.json&lt;/code&gt;, but there&amp;rsquo;s a catch: Claude does not support variable substitution like &lt;code&gt;${env:PATH}&lt;/code&gt;. You&amp;rsquo;d have to hardcode the full &lt;code&gt;PATH&lt;/code&gt;, which breaks the moment dependencies change or you move between machines.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; you get full shell semantics—&lt;code&gt;$PATH&lt;/code&gt; expands at runtime, conditionals work, and subshells are fair game.&lt;/p&gt;
&lt;h2 id="why-shell-init-doesnt-help-either-the-snapshot"&gt;Why shell init doesn&amp;rsquo;t help either: the snapshot&lt;/h2&gt;
&lt;p&gt;There&amp;rsquo;s a deeper reason the obvious fixes fail. At session start, Claude Code freezes a &lt;strong&gt;shell snapshot&lt;/strong&gt; to &lt;code&gt;~/.claude/shell-snapshots/snapshot-zsh-*.sh&lt;/code&gt;, and the Bash tool sources that file before every command it runs.&lt;/p&gt;
&lt;p&gt;The snapshot&amp;rsquo;s &lt;em&gt;final&lt;/em&gt; line is &lt;code&gt;export PATH=&amp;lt;frozen value&amp;gt;&lt;/code&gt;, and that value is Claude Code&amp;rsquo;s own process &lt;code&gt;PATH&lt;/code&gt; captured at startup—not re-derived from your shell config. (You can spot it: it contains directories no rc file adds, like Claude&amp;rsquo;s own plugin and session &lt;code&gt;bin&lt;/code&gt; paths.) Because this &lt;code&gt;export PATH&lt;/code&gt; is the last thing sourced before each command, it clobbers anything your shell init did:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mise activate&lt;/code&gt; in &lt;code&gt;.zshenv&lt;/code&gt;/&lt;code&gt;.zshrc&lt;/code&gt; runs, prepends the shims—then the snapshot&amp;rsquo;s trailing &lt;code&gt;export PATH&lt;/code&gt; overwrites it, burying the shims back where they started.&lt;/li&gt;
&lt;li&gt;Even setting &lt;code&gt;env.PATH&lt;/code&gt; in &lt;code&gt;settings.json&lt;/code&gt; as a plain subprocess variable loses, because the snapshot is sourced &lt;em&gt;after&lt;/em&gt; the process environment is set up.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; wins because it&amp;rsquo;s sourced &lt;strong&gt;after&lt;/strong&gt; the snapshot. Its &lt;code&gt;export PATH=&amp;quot;…/shims:$PATH&amp;quot;&lt;/code&gt; is the last word, so the shims land first for every Bash tool call. That&amp;rsquo;s the whole trick: it&amp;rsquo;s not just convenient shell semantics—it&amp;rsquo;s the one hook that runs late enough to survive the snapshot.&lt;/p&gt;
&lt;h2 id="generating-the-env-file-automatically"&gt;Generating the env file automatically&lt;/h2&gt;
&lt;p&gt;Writing the env file by hand gets tedious. If you use mise, you can generate both files with a task—I vibe-coded &lt;a href="https://github.com/doitian/dotfiles-public/blob/master/src/mise-tasks/preset-claude.js"&gt;&lt;code&gt;preset-claude.js&lt;/code&gt;&lt;/a&gt; for exactly this (it&amp;rsquo;s a custom mise task, not a built-in). It:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reads &lt;code&gt;env._.path&lt;/code&gt; from &lt;code&gt;mise.toml&lt;/code&gt; (so per-project &lt;code&gt;node_modules/.bin&lt;/code&gt; directories land on &lt;code&gt;PATH&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Expands &lt;code&gt;{{config_root}}&lt;/code&gt; to absolute paths.&lt;/li&gt;
&lt;li&gt;Writes &lt;code&gt;.claude/mise.env&lt;/code&gt; with shims, extra paths, and all mise env vars.&lt;/li&gt;
&lt;li&gt;Sets &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; in &lt;code&gt;.claude/settings.local.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;mise run preset:claude
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One command, and Claude sees the exact same tools you do.&lt;/p&gt;
&lt;h2 id="what-about-other-version-managers"&gt;What about other version managers?&lt;/h2&gt;
&lt;p&gt;The pattern works for anything that can write a shell env file:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;asdf&lt;/strong&gt;: &lt;code&gt;asdf direnv envrc &amp;gt; .claude/asdf.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;nix&lt;/strong&gt;: &lt;code&gt;nix print-dev-env | grep ^export &amp;gt; .claude/nix.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;direnv&lt;/strong&gt;: &lt;code&gt;direnv export json | jq -r 'to_entries[] | &amp;quot;export \(.key)=\&amp;quot;\(.value)\&amp;quot;&amp;quot;' &amp;gt; .claude/direnv.env&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then point &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; at it and you&amp;rsquo;re set.&lt;/p&gt;
&lt;h2 id="caveats"&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The file is sourced, not executed—shebang lines are ignored.&lt;/li&gt;
&lt;li&gt;Changes to &lt;code&gt;settings.local.json&lt;/code&gt; take effect on the next Claude session.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; is a relative path, it&amp;rsquo;s resolved from the project root.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A small config file saves you from fighting invisible &lt;code&gt;PATH&lt;/code&gt; issues. Set it once, and Claude runs the same tools you do.&lt;/p&gt;</description><category domain="https://blog.iany.me/post/">Posts</category><category domain="https://blog.iany.me/tags/dev-environment/">Dev Environment</category><category domain="https://blog.iany.me/tags/environment-variables/">Environment Variables</category><category domain="https://blog.iany.me/tags/automation/">Automation</category><category domain="https://blog.iany.me/tags/tool/">Tool</category></item><item><title>Envrc Alternative for PowerShell in Windows</title><link>https://blog.iany.me/2024/04/envrc-alternative-for-powershell-in-windows/</link><pubDate>Sat, 06 Apr 2024 08:55:34 +0800</pubDate><author>me@iany.me (Ian Yang)</author><guid>https://blog.iany.me/2024/04/envrc-alternative-for-powershell-in-windows/</guid><description>&lt;p&gt;This post introduces a solution for automatically setting up and tearing down shell environments for PowerShell in Windows. It is proposed as a potential alternative to the bash-based tool &lt;a href="https://github.com/direnv/direnv"&gt;direnv&lt;/a&gt;, which, while effective at loading &lt;code&gt;.envrc&lt;/code&gt; files in the current or nearest ancestor directory, has limited compatibility with PowerShell in Windows.&lt;/p&gt;
&lt;h2 id="envrcps1"&gt;.envrc.ps1&lt;/h2&gt;
&lt;p&gt;The basic idea is creating a file &lt;code&gt;.envrc.ps1&lt;/code&gt; with two entries: setup and teardown. The setup entry is responsible for setting up the environment up entering the directory, and the teardown entry is used when leaving it.&lt;/p&gt;
&lt;p&gt;PowerShell allows code to be executed from a file using the dot command. This means that the entire file can be used as the setup entry. For the teardown entry, a function can be defined within the same file, and I use the function name &lt;code&gt;down&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The layout of the file &lt;code&gt;.envrc.ps1&lt;/code&gt; looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-powershell"&gt;# setup
$env:GH_TOKEN = &amp;quot;secret&amp;quot;
# teardown
function global:down {
$env:GH_TOKEN = &amp;quot;&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;global:&lt;/code&gt; scope before the function name is necessary when this file is loaded in the hook, as I will do later.&lt;/p&gt;
&lt;p&gt;The corresponding commands for setup and teardown entries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Setup: &lt;code&gt;. .envrc.ps1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Teardown: &lt;code&gt;down&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="hook"&gt;Hook&lt;/h2&gt;
&lt;p&gt;To hook the setup and teardown entries, I reference &lt;a href="https://github.com/PowerShell/PowerShell/issues/14484#issuecomment-1731647083"&gt;this example&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-powershell"&gt;using namespace System;
using namespace System.Management.Automation;
$hook = [EventHandler[LocationChangedEventArgs]] {
param([object] $source, [LocationChangedEventArgs] $eventArgs)
end {
# 1. `down` for $eventArgs.OldPath
# 2. `. .envrc.ps1` for $eventArgs.NewPath
}
};
$currentAction = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction;
if ($currentAction) {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine($currentAction, $hook);
} else {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = $hook;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To run the code snippet, PowerShell 7 or later is required. You can install it from the &lt;a href="https://github.com/PowerShell/PowerShell"&gt;repository&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here is the algorithm for identifying setup and teardown entries:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find the nearest &lt;code&gt;.envrc.ps1&lt;/code&gt; for the old path.&lt;/li&gt;
&lt;li&gt;Find the nearest &lt;code&gt;.envrc.ps1&lt;/code&gt; for the new path.&lt;/li&gt;
&lt;li&gt;If the two paths are the same, skip the next step.&lt;/li&gt;
&lt;li&gt;If they differ, invoke the &lt;code&gt;down&lt;/code&gt; function and remove it if it exists. Then, if found, load the &lt;code&gt;.envrc.ps1&lt;/code&gt; file for the new path.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The full code example to be added into PowerShell &lt;code&gt;$PROFILE&lt;/code&gt; file.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-powershell"&gt;using namespace System;
using namespace System.Management.Automation;
# existing PowerShell profile contents
# skip when version is less than 7
if ($PSVersionTable.PSVersion.Major -lt 7) {
return
}
# find the .envrc.ps1 in the currenct directory or the nearest ancestor directory.
function Find-NearestEnvrc {
param (
[Parameter(Mandatory=$true)]
[string]$StartDir
)
$currentDir = Resolve-Path $StartDir
while ($currentDir -ne &amp;quot;&amp;quot;) {
$envrcPath = Join-Path $currentDir &amp;quot;.envrc.ps1&amp;quot;
if (Test-Path $envrcPath) {
return $envrcPath
}
$currentDir = Split-Path $currentDir -Parent
}
return &amp;quot;&amp;quot;
}
# hook the setup and teardown entries
$hook = [EventHandler[LocationChangedEventArgs]] {
param([object] $source, [LocationChangedEventArgs] $eventArgs)
end {
$oldEnvrc = Find-NearestEnvrc $eventArgs.OldPath
$newEnvrc = Find-NearestEnvrc $eventArgs.NewPath
if ($oldEnvrc -ne $newEnvrc) {
Get-Command down -ErrorAction SilentlyContinu
if (Get-Command down -ErrorAction SilentlyContinu) {
down
Remove-Item Function:down
}
if ($newEnvrc -ne &amp;quot;&amp;quot;) {
. $newEnvrc
}
}
}
};
$currentAction = $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction;
if ($currentAction) {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine($currentAction, $hook);
} else {
$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = $hook;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id="example-usage"&gt;Example Usage&lt;/h2&gt;
&lt;pre&gt;&lt;code class="language-powershell"&gt;# setup
# set environment variable
$env:GH_TOKEN = &amp;quot;secret&amp;quot;
# load python virtual env
. .venv\Scripts\Activate.ps1
# add a helper function
function global:build {
cargo build
}
# teardown
function global:down {
# unset environment variable
$env:GH_TOKEN = &amp;quot;&amp;quot;
# unload python virtual env
deactivate
# remove the helper function
Remove-Item Function:build
}
&lt;/code&gt;&lt;/pre&gt;</description><category domain="https://blog.iany.me/post/">Posts</category><category domain="https://blog.iany.me/tags/environment-variables/">Environment Variables</category><category domain="https://blog.iany.me/tags/powershell/">PowerShell</category><category domain="https://blog.iany.me/tags/windows/">Windows</category></item></channel></rss>