Skip to content

[DOCS] Guide: direnv (+ devbox) environment loading via hooks #42229

@excavador

Description

@excavador

Documentation Type

Missing documentation (feature not documented)

Documentation Location

No response

Section/Topic

direnv, shell, nix, devbox

Current Documentation

Did not explain how to set up claude with direnv, devbox or nix shell

What's Wrong or Missing?

Did not explain how to set up claude with direnv, devbox or nix shell

Suggested Improvement

Summary

Claude Code's Bash tool runs in a non-interactive shell that doesn't source ~/.bashrc, so direnv and devbox environments are not loaded automatically. This guide provides a working hook-based solution that simulates interactive shell behavior — loading and unloading project environments as Claude changes directories.

Related: #2110 (closed)

The problem

  • Claude Code does not source ~/.bashrc when running bash commands
  • direnv hook bash installs a PROMPT_COMMAND hook — purely interactive, never fires
  • devbox global shellenv is evaluated in ~/.bashrc — also never runs
  • Result: project-specific tools (compilers, linters, runtimes from .envrc / devbox) are invisible to Claude

Claude does inherit the parent shell's environment, so global devbox works if you launch claude from a terminal. But switching between project directories with different .envrc files does not update the environment.

Solution

Two files: a hook script and a settings.json entry.

~/.claude/hooks/devbox-and-direnv.sh:

#!/bin/bash
# Simulates interactive shell behavior for devbox global + direnv.
# Works for any combination: no devbox, global only, project-specific,
# with or without direnv. Safe no-op on machines without these tools.

[ -n "$CLAUDE_ENV_FILE" ] || exit 0

has_cmd() { command -v "$1" >/dev/null 2>&1; }

ENV_SNAPSHOT="${CLAUDE_ENV_FILE}.snapshot"

# Append source line to CLAUDE_ENV_FILE only once (it's append-only, shared across hooks)
if ! grep -qF "$ENV_SNAPSHOT" "$CLAUDE_ENV_FILE" 2>/dev/null; then
    echo ". \"$ENV_SNAPSHOT\"" >> "$CLAUDE_ENV_FILE"
fi

# Generate the snapshot — subshell captures all output, overwrites the file
(
    # Load devbox global if: binary exists, global config exists, not already in PATH
    GLOBAL_DEVBOX_PROFILE="${HOME}/.local/share/devbox/global/default/.devbox/nix/profile/default/bin"
    if has_cmd devbox \
       && [ -f "${HOME}/.local/share/devbox/global/default/devbox.json" ] \
       && [[ ":${PATH}:" != *":${GLOBAL_DEVBOX_PROFILE}:"* ]]; then
        _devbox_global=$(devbox global shellenv 2>/dev/null)
        if [ -n "$_devbox_global" ]; then
            eval "$_devbox_global"
            printf '%s\n' "$_devbox_global"
        fi
    fi

    # Load directory-specific env via direnv (handles .envrc with devbox, nix, etc.)
    if has_cmd direnv; then
        direnv export bash 2>/dev/null
    fi

    # IMPORTANT: safety guard — see "Gotchas" section below
    echo "true"
) > "$ENV_SNAPSHOT"

If you only use direnv (no devbox), simplify to:

#!/bin/bash
[ -n "$CLAUDE_ENV_FILE" ] || exit 0

ENV_SNAPSHOT="${CLAUDE_ENV_FILE}.snapshot"

if ! grep -qF "$ENV_SNAPSHOT" "$CLAUDE_ENV_FILE" 2>/dev/null; then
    echo ". \"$ENV_SNAPSHOT\"" >> "$CLAUDE_ENV_FILE"
fi

(
    direnv export bash 2>/dev/null
    echo "true"
) > "$ENV_SNAPSHOT"

~/.claude/settings.json (add to existing settings):

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/devbox-and-direnv.sh || true"
          }
        ]
      }
    ],
    "CwdChanged": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/devbox-and-direnv.sh || true"
          }
        ]
      }
    ]
  }
}

How it works

  1. direnv export bash works in non-interactive shells — outputs export statements for the current directory's .envrc, or nothing if no .envrc exists
  2. SessionStart hook loads the env for the directory where claude was launched
  3. CwdChanged hook reloads the env whenever Claude changes directory
  4. A snapshot file (${CLAUDE_ENV_FILE}.snapshot) is overwritten on each invocation, ensuring clean transitions between projects
  5. CLAUDE_ENV_FILE itself only gets a single . "/path/to/snapshot" line appended (it's append-only)

Gotchas

1. ; && syntax error — the echo "true" guard

direnv export bash outputs lines ending with ;:

export FOO=$'bar';
export BAZ=$'qux';

Claude Code inlines CLAUDE_ENV_FILE content into command strings joined with &&. A trailing ; before && creates ; && — a bash syntax error. The echo "true" at the end ensures the last inlined token is true, making ; true && valid.

2. ~/.claude/session-envs/ cache

Claude Code caches environment variables from previous sessions in ~/.claude/session-envs/. If a previous session ran commands with direnv-loaded env, those cached exports (containing ;) get replayed in new sessions with the same ; && problem.

Fix: delete ~/.claude/session-envs/ and restart claude.

3. CLAUDE_ENV_FILE is append-only

Multiple hooks share one CLAUDE_ENV_FILE per session. Using > would clobber other hooks' writes. The snapshot indirection solves this: CLAUDE_ENV_FILE gets one append, the snapshot is freely overwritten.

4. Don't forget direnv allow

If a project's .envrc hasn't been allowed, direnv export bash silently outputs nothing. Run direnv allow in the project directory first.

Tested transitions

From To Result
~/ (no .envrc) project with go go available
project with go project with jq go gone, jq available
any project non-project dir project env fully unloaded
launched from project dir (stay) env inherited, hook is no-op

Environment

  • Ubuntu, Claude Code 2.1.89
  • devbox 0.17.0 (global mode)
  • direnv (system package)
  • Should work on any Linux/macOS with bash

Impact

High - Prevents users from using a feature

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:hooksdocumentationImprovements or additions to documentationstaleIssue is inactive

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions