Skip to content

miphreal/envrc-tools

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

envrc-tools

A shell integration tool that automatically detects and loads .envrc files in isolated subshells. Unlike direnv (which patches env diffs into your current shell), envrc-tools spawns a new shell process per .envrc — cleanup is automatic when the subshell exits.

Setup

# In .zshrc:
source ~/path/to/envrc.sh; envrc hook zsh

.envrc API

Functions

  • use TOOL [VERSION] — install and activate a tool version via asdf. VERSION defaults to latest (resolved to a concrete version). Activates the version for the current subshell by exporting ASDF_<TOOL>_VERSION.
  • PATH_add DIR — prepend a directory to $PATH.

Variables

  • $ENVRC_DIR — the directory containing the .envrc being loaded.
  • $ENVRC_PROMPT — prompt indicator (e.g. 📜.envrc[project]). Not shown automatically; see Prompt indicator.

Example .envrc

use golang 1.23.2
use python 3.12.2
use nodejs 22.5.1

source .venv/bin/activate
PATH_add "${ENVRC_DIR}/node_modules/.bin"

export GOPATH=$PWD

Verbosity

Set ENVRC_VERBOSE before sourcing envrc.sh:

Level Output
0 (default) Errors only
1 + success messages
2 + info messages
3 + debug messages

Prompt indicator

When an .envrc is loaded, $ENVRC_PROMPT is set (e.g. 📜.envrc[project]) but your prompt is not modified automatically — that would clobber custom themes. To show it, reference $ENVRC_PROMPT in your prompt with prompt_subst on:

setopt prompt_subst
PROMPT='${ENVRC_PROMPT:+$ENVRC_PROMPT }'"$PROMPT"

Architecture

envrc.sh is a self-contained ~200-line script. The key design choice: every .envrc environment runs in its own shell process, so exiting the subshell is a clean teardown with no env patching needed.

Component Diagram

graph TD
    subgraph "User Shell (.zshrc)"
        INIT["source envrc.sh<br>envrc hook zsh"]
    end

    subgraph "envrc.sh — Core"
        HOOK["_envrc_hook(zsh)"]
        CHECK["_envrc_run_check()"]
        FIND["_find_up(.envrc)"]
        LOAD["_load_envrc(path)"]
        SPAWN["_envrc_spawn_subshell(path)"]
        SOURCE["_envrc_source(path)"]
        ONCD["_envrc_on_cd()"]
    end

    subgraph "envrc.sh — Utilities"
        REL["_user_rel_path()"]
        LOG["_debug / _info / _success / _error"]
    end

    subgraph ".envrc API"
        USE["use TOOL [VER]"]
        PATHADD["PATH_add DIR"]
    end

    subgraph "External"
        ASDF["asdf (version manager)"]
        TMPFILE["/tmp/envrc-subshell-last-pwd-$$"]
    end

    INIT --> HOOK
    HOOK -->|"add-zsh-hook precmd"| CHECK
    HOOK -->|"add-zsh-hook chpwd"| ONCD
    CHECK --> FIND
    FIND -->|"found .envrc"| LOAD
    LOAD -->|"level 0 or ENVRC set"| SPAWN
    LOAD -->|"level >0 and ENVRC empty"| SOURCE
    SPAWN -->|"exec $SHELL"| CHECK
    SOURCE -->|"source .envrc"| USE
    SOURCE -->|"source .envrc"| PATHADD
    USE --> ASDF
    CHECK -->|"outside ENVRC_DIR"| TMPFILE
    SPAWN -->|"read last PWD"| TMPFILE
Loading

Lifecycle: Enter & Exit Flow

sequenceDiagram
    participant User
    participant Login as Login Shell (level 0)
    participant Sub as Subshell (level 1)
    participant Envrc as .envrc file
    participant Tmp as /tmp/last-pwd

    Note over User,Login: — Entering a directory —
    User->>Login: cd ~/project
    Login->>Login: precmd → _envrc_run_check()
    Login->>Login: _find_up(".envrc") → ~/project/.envrc
    Login->>Login: _load_envrc() → level 0 → spawn subshell
    Login->>Sub: exec $SHELL (level=1, ENVRC="")
    Sub->>Sub: precmd → _envrc_run_check()
    Sub->>Sub: _find_up(".envrc") → same file
    Sub->>Sub: _load_envrc() → level 1, ENVRC="" → source
    Sub->>Envrc: source .envrc
    Envrc-->>Sub: sets env vars, PATH, tools
    Note over Sub: ENVRC_PROMPT = 📜.envrc[project]

    Note over User,Login: — Leaving the directory —
    User->>Sub: cd /other
    Sub->>Sub: precmd → _envrc_run_check()
    Sub->>Sub: PWD not under ENVRC_DIR
    Sub->>Tmp: write PWD
    Sub->>Sub: exit
    Sub-->>Login: subshell terminates
    Login->>Tmp: read last PWD
    Login->>Login: cd to last PWD
    Login->>Login: set _ENVRC_NESTED_UNLOADED (guard)
    Login->>Login: _envrc_run_check() → skip (guard match)
Loading

_load_envrc Decision Tree

flowchart TD
    A["_load_envrc(path)"] --> B{"path == _ENVRC_NESTED_UNLOADED?"}
    B -->|Yes| C["Skip — prevents reload loop"]
    B -->|No| D{"ENVRC set OR<br>nesting level == 0?"}
    D -->|Yes| E["Spawn subshell<br>Clear ENVRC_*, level++, exec $SHELL"]
    D -->|No| F{"ENVRC empty AND<br>nesting level > 0?"}
    F -->|Yes| G["Source .envrc directly<br>Set ENVRC, ENVRC_DIR,<br>ENVRC_NAME, ENVRC_PROMPT,<br>_ENVRC_OWNER_PID"]
    F -->|No| H["No action"]

    E --> I["On subshell exit:<br>set _ENVRC_NESTED_UNLOADED<br>restore PWD"]

    style C fill:#f9f,stroke:#333
    style E fill:#bbf,stroke:#333
    style G fill:#bfb,stroke:#333
Loading

Nesting State Machine

stateDiagram-v2
    [*] --> LoginShell: source envrc.sh

    LoginShell --> Subshell_L1: cd into dir with .envrc\n(spawn subshell, level++)

    state Subshell_L1 {
        [*] --> Loaded: source .envrc
        Loaded --> Loaded: stay in dir
        Loaded --> [*]: cd outside → exit
    }

    Subshell_L1 --> LoginShell: subshell exits\n(_ENVRC_NESTED_UNLOADED = path)

    Subshell_L1 --> Subshell_L2: cd into nested dir\nwith different .envrc

    state Subshell_L2 {
        [*] --> Loaded2: source .envrc
        Loaded2 --> [*]: cd outside → exit
    }

    Subshell_L2 --> Subshell_L1: subshell exits
Loading

Key Design Notes

  • Isolation via process stack: each .envrc is a shell process; nesting creates a stack of shells. Clean by design, but deep nesting = many processes.
  • IPC is a temp file: /tmp/envrc-subshell-last-pwd-$$ (PID-scoped) passes PWD from exiting subshell to parent.
  • No trust model: unlike direnv's allow/deny, any .envrc is auto-sourced.
  • Load failures are surfaced: if a .envrc errors while sourcing, _envrc_source reports it and skips the prompt indicator instead of falsely claiming success.
  • Reload guard (_ENVRC_NESTED_UNLOADED): prevents infinite re-spawn when parent rediscovers its own .envrc after subshell exit; cleared on next chpwd, and re-asserted after the restore cd (which itself fires chpwd) when returning into a subdir of the exited tree.
  • Owner-PID gate (_ENVRC_OWNER_PID): only the shell that sourced the .envrc auto-exits when cwd leaves the tree. A shell started manually from inside a subshell (zsh, ssh-to-localhost, a new tmux pane) inherits the exported ENVRC_* but not a matching $$, so it isn't unexpectedly killed.

Testing

bash tests/run.sh

Runs shellcheck and bats tests inside Docker. CI is configured in .github/workflows/tests.yml.

Known Limitations

  • zsh only — subshells exec $SHELL, so it must be zsh (a warning is printed otherwise)
  • Prompt-bound, not command-bound — load/unload happen before the next prompt, so cd dir && cmd runs cmd before the .envrc subshell spawns (and cd .. && cmd runs once with the still-loaded env)
  • No security allowlist — any .envrc is sourced automatically
  • No change detection — editing .envrc requires leaving and re-entering the directory
  • Each active .envrc is a shell process — deep nesting means a stack of processes
  • Loads the nearest .envrc walking upward — does not merge or cascade multiple files

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors