Skip to content

envoke vs. direnv

direnv is the tool most people already have when they find envoke, so this page is the honest point-by-point: what each one is shaped for, where they overlap, and when it's worth swapping — or running both.

The short version

direnv manages environment variables, declared by a file inside each directory, and restores them for you when you leave.

envoke runs arbitrary shell scripts on enter and leave, declared by path pattern, in configs that live in your config directory — one file, or one per project in envokerc.d. A project's own config can join in, but by a symlink you create, never by walking into the directory.

Neither replaces the other outright. direnv's stdlib (layout python, use nix, dotenv) and its automatic environment restore have no equivalent here. envoke is for rules that belong to you, or that do things which aren't environment variables.

Side by side

direnv envoke
Where rules live An .envrc in each directory, found by walking into it Your own config directory — one central file and/or envokerc.d fragments — selecting directories by path pattern
How a directory is selected By the file's own location RE2 regex matched against whole path segments, capture groups exposed to the script
What it can change Environment variables — exported vars captured from a bash subshell Anything shell code can do; the hook's output is eval'd by your own shell
Leaving a directory Automatic: the previous environment is restored Explicit: you write a leave block; nothing is auto-undone
Script language bash, always — even if your shell is fish or PowerShell Your shell's own syntax
Trust step direnv allow, per .envrc envoke allow, once per config file and covering the whole set by default, showing a diff on re-approval
Shells bash, zsh, fish, tcsh, elvish, pwsh, murex (+ export formats) bash, zsh, fish, tcsh, PowerShell
Batteries Large stdlib: layout python, use nix, PATH_add, dotenv, … None — you write plain shell
Runtime dependencies Needs bash available to evaluate .envrc One static binary, no cgo, nothing else

1. The rules don't have to live in the directory

This is the difference that brings most people here. direnv needs a file in the directory it acts on, which is a poor fit when:

  • the repository isn't yours, or your team doesn't want an .envrc committed;
  • you'd rather not add another entry to .gitignore in every checkout;
  • the rule is really "everything under ~/work/", not "this one project";
  • you don't want to run an approval command again for each new clone.

It's a long-standing ask on direnv's own tracker — see direnv#1108 "Allow external dir mappings", direnv#280 "How about having a global envrc that describes all direnvs", and direnv#1311.

envoke starts from the other end: one config you own, patterns that cover whole trees, and nothing dropped inside the directories.

# ~/.envokerc — applies to every project under ~/work, including ones cloned tomorrow
enter ~/work/([^/]+)
    export PROJECT_NAME="$ENVOKE_MATCH_1"
    export AWS_PROFILE=work

And when they should live in the repository, they can — but you opt in. A config committed inside a project works, with patterns written relative to itself, and joins the set through a symlink you create:

ln -s ~/work/api/envoke.conf ~/.config/envoke/envokerc.d/api
# ~/work/api/envoke.conf — committed with the repository
enter .
    export PROJECT_ROOT="$ENVOKE_DIR"

enter ./services/([^/]+)
    export SERVICE="$ENVOKE_MATCH_1"

That is the deliberate difference from direnv: envoke never reads a config because you walked into the directory holding it. The link is the decision. Such a config is also confined to its own directory tree — however its patterns are written, it cannot fire outside the project — and is approved separately from your central one.

The flip side, stated plainly: direnv's per-project model is more than a file location. Its stdlib (layout python, use nix, dotenv) is what most .envrc files actually consist of, and envoke has no equivalent: you write the shell yourself.

See Configuration for the full pattern syntax and the ENVOKE_* variables a matched block receives.

2. It isn't only about environment variables

direnv evaluates .envrc in a bash subshell and captures the exported variables out of it. That's a deliberate, robust design — it's what lets one implementation serve every shell — but it also means anything that isn't an environment variable doesn't survive the trip back: shell functions, aliases, shell options, a sourced activation script that defines functions.

envoke's shell hook doesn't run your script in a subprocess at all. It emits shell text in your shell's dialect, which your own shell evaluates — so source, alias, set -o, and shell functions all work as written:

enter ~/Projects/([^/]+)
    source "$ENVOKE_DIR/venv/bin/activate"
    alias t='go test ./...'

leave ~/Projects/([^/]+)
    deactivate
    unalias t

The same applies to side effects that live outside the shell entirely — switching a kubectl context, renaming a tmux window, adding an ssh key, tightening umask.

3. Leaving is a script you write

direnv restores the environment it changed, automatically. That's genuinely less work, and for pure environment variables it's the better default.

envoke deliberately does not do this. There's no state snapshot and no auto-undo — a leave block runs only what you wrote in it. The cost is explicit teardown code; the benefit is that teardown can undo things direnv can't restore, because they were never environment variables:

enter ~/Projects/infra
    kubectl config use-context staging

leave ~/Projects/infra
    kubectl config use-context default

This is a design principle, not a missing feature — see Design Notes.

4. Matching is a regex over path segments

Because rules are central, envoke needs a way to say which directories a block applies to. Patterns compile to Go's regexp (RE2), so matching is linear-time no matter how the pattern is written, and they're anchored so they match whole path segments~/Projects/foo never matches ~/Projects/foobar. Capture groups reach the script as ENVOKE_MATCH_1, ENVOKE_MATCH_2, … so one block can cover a whole family of directories.

Moving straight from /a to /a/x/y/z fires the rules for /a/x and /a/x/y too, not just the endpoint.

direnv has no equivalent concept, and doesn't need one — the file's location is the match.

5. Both gate execution, differently

Neither tool will run code you haven't approved. Both hash the approved content, and both revoke trust the moment it changes.

The differences are ergonomic:

  • One approval covers a whole tree. envoke's unit of trust is the config file, so a central rule for ~/work/([^/]+) is approved once and applies to every clone under it — including tomorrow's. And envoke allow with no argument covers every config at once, however many files you split into.
  • A diff on re-approval. After the first envoke allow, later approvals show what changed since the version you trusted, instead of re-dumping the whole file for you to re-read.
  • Nothing to approve that you didn't add. direnv asks about any .envrc you walk into. envoke only ever loads configs from your own config directory, so an unapproved config is one you put there yourself.
  • No cross-directory unloading. In direnv, an untrusted .envrc in a subdirectory unloads the trusted parent environment as well — see the discussion in direnv#1493. In envoke each config is independent: an untrusted one is reported and skipped, and the trusted ones still run.

envoke also warns when the config file is group- or other-writable: hash-based trust protects against silent modification, not against another local user editing the file outright.

Details in Trust Model, and envoke debug shows exactly which blocks a given move would fire — without executing anything.

Can I run both?

Yes. They hook the shell independently and don't interfere; a common split is direnv for repositories that ship their own .envrc, envoke for your own machine-wide rules across trees. The only thing to watch is that if both set the same variable for the same directory, whichever hook runs last wins — so keep them responsible for different things.

Moving a project over

A typical .envrc:

export DATABASE_URL=postgres://localhost/myapp_dev
export AWS_PROFILE=myapp-dev
PATH_add bin

The equivalent in ~/.envokerc — note that the teardown is now yours to write, and that $ENVOKE_DIR is the directory that matched (which isn't necessarily where you ended up, if you jumped several levels deep):

enter ~/Projects/myapp
    export DATABASE_URL=postgres://localhost/myapp_dev
    export AWS_PROFILE=myapp-dev
    export PATH="$ENVOKE_DIR/bin:$PATH"

leave ~/Projects/myapp
    unset DATABASE_URL AWS_PROFILE
    export PATH="$(printf '%s' "$PATH" | tr ':' '\n' \
        | grep -vxF "$ENVOKE_DIR/bin" | paste -sd: -)"

The PATH teardown looks heavier than a ${PATH#...} prefix strip because that shorter form only works while the entry is still leftmost — see Recipes for why removal by whole line is the version that survives something else being prepended.

Then envoke allow to review and approve it — until you do, none of it runs.

What about ondir, mise, and shadowenv?

  • ondir — the same model envoke targets: enter/leave blocks matched by path, in one central config. It's feature-complete by its own maintainer's account and still works, but hasn't seen a release in years. Design Notes lists the specific points where envoke departs from it.
  • mise — tool-version manager that also covers direnv's env use case via a per-project mise.toml. Same shape as direnv for the purposes of this page: the rules live in the project.
  • shadowenv — per-directory .shadowenv.d, an s-expression DSL, environment-focused. Again, a file in the directory.

The recurring theme: everything else in this space puts a file in the directory and manages environment variables. envoke is the option that keeps the rules in one place you control and lets a block run whatever you want.

Try it

Getting Started is the whole path: install, hook your shell, write a first block, envoke allow, cd. Five minutes, and envoke debug will tell you what it is about to do before it does it.