Skip to main content

max / alloy

shell: give direnv a nushell hook direnv shipped in the image and did nothing. It emits no nu init for the build-time trick used on starship and zoxide to call: as of 2.35.0 its hook and export targets are bash, zsh, fish, elvish, tcsh, murex, pwsh, vim, systemd, gha, json and gzenv, and there is no sign of nu arriving. So this one is written by hand, against `direnv export json`, which is a supported target rather than a workaround and hands over the environment diff in the shape nushell already wants. Lands in the vendor autoload directory next to the generated integrations, so every integration is in one place whether it was authored or generated. Registers a pre_prompt hook and a `direnv-reload` command for use by hand after editing an .envrc. Two details the implementation turns on, both measured on nushell 0.112.2 rather than assumed: Load order is env.nu, config.nu, vendor autoload, user autoload. The autoload pass running after config.nu is what makes the hook stick, since config.nu assigns $env.config wholesale and would discard a hook registered any earlier. The Containerfile claimed the opposite order and that its reason for build-time generation was that a file written at shell start is not seen until the next shell; neither holds. The real reason is cost, and it is now written that way. config.nu's hooks block says out loud that it is a reset, so the next person to add a hook knows where it has to go. PATH is applied apart from the rest of the diff. nushell holds it as a list and direnv reports it colon-joined, so loading the diff verbatim leaves a shell whose PATH it cannot search. Verified in the image with a real .envrc: entering the directory sets the variable and prepends to PATH as a list, with the added binary executable by name; leaving unsets it, and a null from direnv removes the variable from a child process rather than passing it through empty.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 00:27 UTC
Signed with PGP, not checked
Commit: c745d9f07a23c4c7b06ac753f2bceb08cf40c41e
Parent: c407451
5 files changed, +67 insertions, -8 deletions
M Containerfile +10 -3
@@ -340,9 +340,16 @@
340 340 # "Value is not a parse-time constant" and, because the failure is a parse
341 341 # error, nushell discards the whole config file. The account then gets
342 342 # stock nushell: no theme, no vi mode, no history settings, no aliases,
343 - # and the banner that config.nu turns off. The second reason is ordering:
344 - # the autoload directories are read before env.nu's writes land, so a file
345 - # generated at shell start is not seen until the *next* shell.
343 + # and the banner that config.nu turns off. The second reason is cost:
344 + # each init spawns a process to re-render output that cannot change
345 + # between shells on a read-only /usr.
346 + #
347 + # Load order, measured on nushell 0.112.2 rather than assumed: env.nu,
348 + # then config.nu, then the vendor autoload dirs, then the user's. The
349 + # autoload pass running *after* config.nu is load-bearing for the
350 + # hand-authored direnv.nu that the config tree below drops into this same
351 + # directory. config.nu assigns `$env.config` wholesale, hooks included, so
352 + # a hook registered any earlier would be discarded before it ever ran.
346 353 #
347 354 # Guarded on the binaries so this step does not fail the build if either
348 355 # package leaves the image; nushell simply autoloads whatever is here.
M docs/SHELL.md +3 -1
@@ -35,7 +35,9 @@
35 35
36 36 Where daily-use friction shows up, and how Alloy handles it:
37 37
38 - - **Tool init lines.** `eval "$(starship init bash)"` and equivalents. Starship, zoxide, atuin, and carapace all emit a nu init directly, and the image generates those at build time into `/usr/share/nushell/vendor/autoload/`. **direnv does not.** As of 2.35.0 its `hook` and `export` targets are bash, zsh, fish, elvish, tcsh, murex, pwsh, vim, systemd, gha, json, and gzenv, with no nu among them; `direnv export json` is the seam a nu hook has to be built on. For that and the rest of the long tail, Alloy ships small wrapper snippets in `etc/skel/.config/nushell/integrations/`, added on demand rather than preemptively.
38 + - **Tool init lines.** `eval "$(starship init bash)"` and equivalents. Starship, zoxide, atuin, and carapace all emit a nu init directly, and the image generates those at build time into `/usr/share/nushell/vendor/autoload/`. **direnv does not.** As of 2.35.0 its `hook` and `export` targets are bash, zsh, fish, elvish, tcsh, murex, pwsh, vim, systemd, gha, json, and gzenv, with no nu among them. Alloy therefore writes that one by hand, against `direnv export json`, and ships it into the same autoload directory: [`usr/share/nushell/vendor/autoload/direnv.nu`](../usr/share/nushell/vendor/autoload/direnv.nu). Generated or authored, every integration lands in one place. The rest of the long tail is handled the same way, added on demand rather than preemptively.
39 +
40 + Two things that directory's ordering settles. Nushell reads it *after* `config.nu`, which is what lets an autoload file register a hook: `config.nu` assigns `$env.config` wholesale and would discard anything registered earlier. And `$env.PATH` is a list in nu while direnv reports PATH colon-joined, so the hook converts that one variable rather than loading the diff verbatim.
39 41 - **Interactive one-liners.** `FOO=bar cmd && cmd2` becomes `FOO=bar; cmd; cmd2` (or `try / catch` for the conditional flavor). Muscle-memory tax; not a real barrier.
40 42 - **`export`.** `$env.FOO = "bar"` in nu, `export FOO=bar` in bash. Both work in their respective contexts.
41 43 - **Piped stdin.** Third-party CLIs that expect line-oriented text stdin: nu's default output is a table, but piping to an external command auto-flattens to text. Explicit control via `| to text` when needed.
M docs/STACK.md +1 -1
@@ -190,7 +190,7 @@
190 190
191 191 Also rejected: autoenv (bare `source`, no security model, no unload), and per-project `nu` scripts sourced by hand (not automatic, which is the whole feature).
192 192
193 - **Known gap: direnv ships inert.** It has no nushell hook, so the build-time autoload trick used for starship and zoxide has nothing to call. `direnv export json` is the supported seam, and the integration is an Alloy-authored snippet in `etc/skel/.config/nushell/integrations/` per [SHELL.md](SHELL.md#interoperability). Until that lands the binary is present and does nothing under the login shell.
193 + **The nu integration is Alloy-authored**, at [`usr/share/nushell/vendor/autoload/direnv.nu`](../usr/share/nushell/vendor/autoload/direnv.nu). direnv emits no nu init for the build-time trick used on starship and zoxide to call, so the hook is written against `direnv export json`, a supported export target. It registers a `pre_prompt` hook and a `direnv-reload` command for use by hand after editing an `.envrc`. PATH is applied apart from the rest of the diff, since nushell holds it as a list and direnv reports it colon-joined.
194 194
195 195 ## Utility defaults: content viewers
196 196
@@ -161,6 +161,11 @@
161 161 shape_garbage: { fg: $alloy.accent_error attr: b }
162 162 }
163 163
164 + # This record is assigned wholesale, so these lists are a reset, not a
165 + # starting point: a hook registered before this file runs is discarded
166 + # here. Nothing does, and nothing should. The autoload directory is
167 + # read after this file (direnv's hook lives there) and is the place to
168 + # add one, per-user hooks go below any of this in your own config.
164 169 hooks: {
165 170 pre_prompt: []
166 171 pre_execution: []
@@ -178,9 +183,11 @@
178 183 # -------------------------------------------------------------------
179 184 # Integrations
180 185 # -------------------------------------------------------------------
181 - # starship and zoxide are not set up here. The image generates their init
182 - # files into /usr/share/nushell/vendor/autoload/, which nushell sources by
183 - # itself before this file runs. See the Containerfile for why: `source` is
186 + # starship, zoxide and direnv are not set up here. They live in
187 + # /usr/share/nushell/vendor/autoload/, which nushell sources by itself
188 + # after this file runs (the first two generated at build time from their
189 + # own init output, direnv hand-authored because it has no nu init to
190 + # generate). See the Containerfile for why: `source` is
184 191 # a parse-time keyword, so it cannot take a path a `let` computed and it
185 192 # cannot be gated behind a runtime `if`. Written that way it was a parse
186 193 # error, and a parse error costs the whole file, not just its own line.
@@ -1,0 +1,43 @@
1 + # direnv, wired into nushell by hand.
2 + #
3 + # The other integrations in this directory are generated at image build time
4 + # from each tool's own init output (see the Containerfile). direnv has no
5 + # such output to generate: as of 2.35.0 its `hook` and `export` targets are
6 + # bash, zsh, fish, elvish, tcsh, murex, pwsh, vim, systemd, gha, json and
7 + # gzenv, with no nu among them. `json` is the seam this file is built on,
8 + # and it is a supported target rather than a workaround: `direnv export
9 + # json` prints the pending environment diff as a JSON object, which is the
10 + # shape nushell wants anyway.
11 + #
12 + # In that object a value is the variable's new value and a null means unset.
13 + # direnv's own status lines ("direnv: loading .envrc") go to stderr, so they
14 + # reach the terminal untouched while stdout stays parseable.
15 + #
16 + # This file lands in the vendor autoload directory, which nushell reads
17 + # after config.nu. That ordering is what makes the hook below stick:
18 + # config.nu assigns $env.config wholesale, hooks included, so anything
19 + # registered before it would be discarded.
20 +
21 + # Apply direnv's pending environment diff to this shell. Called from the
22 + # pre_prompt hook below, and useful by hand after editing an .envrc.
23 + def --env direnv-reload [] {
24 + if (which direnv | is-empty) { return }
25 +
26 + let diff = (direnv export json | str trim)
27 + if ($diff | is-empty) { return }
28 + let diff = ($diff | from json)
29 +
30 + # PATH is handled apart from the rest. nushell keeps it as a list and
31 + # direnv reports it the way the rest of the world writes it, as one
32 + # colon-joined string, so loading it verbatim would leave the shell with
33 + # a PATH it cannot search.
34 + $diff | reject --optional PATH | load-env
35 + if "PATH" in ($diff | columns) {
36 + let raw = ($diff | get PATH)
37 + $env.PATH = (if $raw == null { [] } else { $raw | split row (char esep) })
38 + }
39 + }
40 +
41 + $env.config.hooks.pre_prompt = (
42 + $env.config.hooks.pre_prompt | default [] | append {|| direnv-reload }
43 + )