I’ve been using Nix for a few months now, and one of my largest outstanding pain points is that any time I’m in a nix-shell, none of my stuff works the way I want it to.

On a scale from “uses the stock PS1” to “wrote their own shell,” I’m prettttty far to the right. I haven’t actually written my own shell, but, you know, I’ve started writing my own shell, and I have lots of aliases and a fancy custom fzf-based autocomplete thing and weird tmux integrations and strong feelings about how my shell should behave.

And I cannot tell you the number of times I have seen this error message:

[nix-shell:~]$ ll
bash: ll: command not found

It’s very annoying.

And yes: I know that I can just run zsh from within my nix-shell. I made my .zshenv not do anything to my PATH when I’m running IN_NIX_SHELL, so that it works correctly, and doesn’t clobber the nix-shell environment.

And I do do that, sometimes. But it’s terrible, and it makes leaving and re-entering the nix-shell really annoying, and usually I only remember to do it because I run into some problem in the first place and anyway whatever I don’t have to justify this to you.

I like zsh, and I want nix-shell to run zsh.

So. All the way back in part 16, I learned that I could run this:

$ NIX_BUILD_SHELL=zsh nix-shell
bash: no such option: rcfile

If I wanted a fun little confusing error message. My conclusion at the time was that it actually was invoking zsh, but it was invoking it as bash – i.e., an argv[0] of "bash", hence the weird error message. That still sounds plausible to me.

But I don’t actually know what zsh… is? Like, is that getting the zsh on my PATH? Is that the derivation named zsh? Is that the derivation at nixpkgs.zsh?

man nix-shell only says:

NIX_BUILD_SHELL

Shell used to start the interactive environment. Defaults to the bash found in PATH.

Which is… weird, right? That it’s not some Nix derivation; that it’s doing an actual PATH lookup. But it doesn’t say “defaults to bash;” it says the bash found in PATH… it’s very weird.

And by now I’ve read all of the manuals, so I know there’s nothing in the manuals about this. So in order to learn more, I’ve got to dive into the source.

Doing so teaches me that nix-shell is implemented in nix-build.cc, which does make some sense to me: nix-shell and nix-build are basically identical when they realize dependencies and setting up the environment; they only really vary in what command they end up running in that environment.

Anyway, here’s a comment that implies that the man page is lying:

/* Figure out what bash shell to use. If $NIX_BUILD_SHELL
   is not set, then build bashInteractive from
   <nixpkgs>. */

And indeed, that comment is accurate; the documentation is in fact lying. I am sort of relieved, because this behavior is much more reasonable than the thing in the man page. Although it does do that less reasonable thing as a last resort, if it cannot build nixpkgs.bashInteractive, with a warning:

printError("warning: %s; will use bash from your environment", e.what());

So okay. Then it writes down a little file, of initialization stuff. Can I see that file? I should be able to…

[nix-shell:~]$ ps $$
  PID   TT  STAT      TIME COMMAND
46836 s004  S      0:01.24 bash --rcfile /private/tmp/nix-shell-46836-0/rc

[nix-shell:~]$ cat /private/tmp/nix-shell-46836-0/rc
cat: /private/tmp/nix-shell-46836-0/rc: No such file or directory

But it seems that the first thing that file does is destroy itself. How do I know? Well, I can see the code that generates the file, but it’s a mess of string interpolation, and does not make very pleasant reading.

There is no --keep-tmp or any sort of flag to nix-shell, but reading the source teaches me that I can keep the temp directory around by having a single passAsFile argument

[nix-shell:~/src/mixologician]$ cat shell.nix
with import <nixpkgs> {}; mkShell {
  nativeBuildInputs = [ souffle python39Packages.cram ];
  passAsFile = ["passAsFile"];
}
[nix-shell:~/src/mixologician]$ cat $(ps $$ -o command= | awk '{print $NF}')
[ -n "$PS1" ] && [ -e ~/.bashrc ] && source ~/.bashrc; p=$PATH; dontAddDisableDepTrack=1; [ -e $stdenv/setup ] && source $stdenv/setup; PATH=$PATH:$p; unset p; PATH="/nix/store/n6kzbax7sjy3kha78hpfh024ghbykgxa-bash-interactive-4.4-p23/bin:$PATH"; SHELL=/nix/store/n6kzbax7sjy3kha78hpfh024ghbykgxa-bash-interactive-4.4-p23/bin/bash; set +e; [ -n "$PS1" ] && PS1='\n\[\033[1;32m\][nix-shell:\w]\$\[\033[0m\] '; if [ "$(type -t runHook)" = function ]; then runHook shellHook; fi; unset NIX_ENFORCE_PURITY; shopt -u nullglob; unset TZ; shopt -s execfail;

Nice. Let me try to prettify that for you:

[ -n "$PS1" ] && [ -e ~/.bashrc ] && source ~/.bashrc

p=$PATH
dontAddDisableDepTrack=1
[ -e $stdenv/setup ] && source $stdenv/setup
PATH=$PATH:$p
unset p

PATH="/nix/store/n6kzbax7sjy3kha78hpfh024ghbykgxa-bash-interactive-4.4-p23/bin:$PATH"
SHELL=/nix/store/n6kzbax7sjy3kha78hpfh024ghbykgxa-bash-interactive-4.4-p23/bin/bash

set +e

[ -n "$PS1" ] && PS1='\n\[\033[1;32m\][nix-shell:\w]\$\[\033[0m\] '

if [ "$(type -t runHook)" = function ]; then
  runHook shellHook
fi

unset NIX_ENFORCE_PURITY
shopt -u nullglob
unset TZ
shopt -s execfail

Okay. So there’s some interesting info here.

I don’t have a ~/.bashrc so I don’t need to worry about that bit.

This nix command has knowledge of the internal workings of $stdenv/setup from Nixpkgs: dontAddDisableDepTrack is meaningful to that script. This is a weird amount of coupling, but like, not that weird. nix-shell is a user-friendly helper thing, not some primitive fundamental part of Nix, so the fact that it “knows” a lot about Nixpkgs isn’t that upsetting.

I had assumed that my PATH was set in a nix-shell because stdenv.mkDerivation added an implicit shellHook to my derivation to set it. But it doesn’t! And that kinda makes sense, when I think about it, since shellHooks are like… propagated? Right? So every one of my dependencies would set their own PATH and that would be kind of dumb. But I guess you could have some mechanism of non-propagated shellHooks or something. I dunno. It doesn’t really matter. It works by implicitly sourcing $stdenv/setup instead.

The -n $PS1 thing is pretty bizarre. It’s checking if PS1 is set to a nonempty string… and then clobbering it if it is? Weird. Weird. I guess this is like… an attempt to detect if it’s running interactively or not? I don’t know.

Anyway; all of this is fine. I can’t just source this as if it were a zsh script – shopt is a bash builtin, and I’m sure something in $stdenv/setup is not compatible – but I can source it from bash and then exec zsh, I think, more or less like I’ve been doing manually.

But first, let’s see what happens when we set NIX_BUILD_SHELL=zsh. The only difference is these two lines:

PATH=".:$PATH"
SHELL=zsh

Which… huh. Okay. So clearly it expects an absolute path. That’s easy enough:

$ echo $SHELL
/nix/store/bainh95kdn1h0gy5rdayjj7qq15a6q46-zsh-5.8/bin/zsh

$ NIX_BUILD_SHELL=$SHELL nix-shell
bash: no such option: rcfile

Alright. Now we’re talking. The code confirms that it’s invoking us as bash; all we need to do now is handle the --rcfile correctly.

But.

Surely someone has done this already, right? I nix search zsh, and find these relevant-looking hits:

* nixpkgs.any-nix-shell (any-nix-shell-1.2.1)
  fish and zsh support for nix-shell

* nixpkgs.zsh-nix-shell (zsh-nix-shell)
  zsh plugin that lets you use zsh in nix-shell shell

I don’t like the sound of “plugin.” I want, like, a tiny wrapper script. I look through the source of both of them, and am not very satisfied.

zsh-nix-shell is in fact a plugin, and the installation instructions are like… scary and weird and involve installing a custom zsh derivation, and I am not there yet.

any-nix-shell similarly wants to do a bunch of fancy stuff and wants me to source it instead of just being the thing I set NIX_BUILD_SHELL to. It seems very complicated, and all I want to do is like… ignore a single command line argument.

So I write my own.

In the course of doing so I learn that my system bash actually can’t handle all the setup hooks that are run in this trivial derivation.

/nix/store/7qk9mqi411ip2jh10d1bbj7x7mgrfksg-cctools-binutils-darwin-wrapper-949.0.1/nix-support/setup-hook:
line 134: ${cmd^^}${role_post}=${cmd}: bad substitution

I have to source it with nixpkgs.bashInteractive instead. Huh.

But there’s one more thing to worry about. Because nix-shell is going to invoke zsh as bash, I’m worried that zsh might put us in some sort of weird compatibility mode. man zsh says it will emulate sh or ksh when invoked as such, but the man page does not saying anything about bash

Actually, no. Darn. I need to read more closely. From man zsh:

Zsh tries to emulate sh or ksh when it is invoked as sh or ksh respectively; more precisely, it looks at the first letter of the name by which it was invoked, excluding any initial ‘r’ (assumed to stand for ‘restricted’), and if that is ‘b’, ‘s’ or ‘k’ it will emulate sh or ksh. Furthermore, if invoked as su (which happens on certain systems when the shell is executed by the su command), the shell will try to find an alternative name from the SHELL environment variable and perform emulation based on that.

Okay. So the emulation mode seems rather innocuous, reading about it. But just to be sure, I’ll invoke zsh with --emulate zsh.

Anyway, here’s the first working attempt at my little wrapper script:

$ cat ~/bin/nix-zshell
#!/nix/store/n6kzbax7sjy3kha78hpfh024ghbykgxa-bash-interactive-4.4-p23/bin/bash

if [[ "$1" != "--rcfile" ]]; then
  echo "Something is wrong: invoked as:" >&2
  echo "$0 $@" >&2
  exit 1
fi

rcfile="$2"
source "$rcfile"

exec zsh --emulate zsh

Obviously this is dumb; it hardcodes the path to bashInteractive (which probably won’t exist, after a good garbage collection) and it invokes the zsh on my PATH instead of nixpkgs.zsh. But in order to fix it, we need to make it into a derivation. Easy enough:

$ cat nix-zshell
#!@bashInteractive@/bin/bash

if [[ "$1" != "--rcfile" ]]; then
  echo "Something is wrong: invoked as:" >&2
  echo "$0 $@" >&2
  exit 1
fi

rcfile="$2"
source "$rcfile"

exec @zsh@/bin/zsh --emulate zsh
$ cat default.nix
with import <nixpkgs> {};
stdenvNoCC.mkDerivation {
  name = "nix-zshell";

  script = substituteAll {
    src = ./nix-zshell;
    inherit zsh bashInteractive;
  };

  phases = ["installPhase"];

  installPhase = "install $script $out";
}

That was my first time using substituteAll. It’s pretty nice! I had to look in the source to see the usage, because it doesn’t seem to be documented in the Nixpkgs manual? Weird. The shell function is documented, but not the incredibly-useful derivation helper.

Okay, so this works. I set it as my NIX_BUILD_SHELL, and in order to make it more nice, I add a few lines to my .zshrc:

if [[ -n "$IN_NIX_SHELL" ]]; then
  label="nix-shell"
  if [[ "$name" != "$label" ]]; then
    label="$label:$name"
  fi
  export PS1=$'%{$fg[green]%}'"$label $PS1"
  unset label
fi

("$name" == "nix-shell" when you use the mkShell helper).

And I’m done! For now. We’ll see how this holds up. It’s certainly going to be nice to have my tab completion back.

This also has an interesting subtle benefit that I didn’t think of beforehand:

I previously learned how to prevent my shell-dependencies from being garbage collected. But it was weirdly incomplete. Observe:

$ nix-instantiate shell.nix --indirect --add-root ./.nix-gc-roots/shell.drv >/dev/null
$ nix-collect-garbage
finding garbage collector roots...
deleting garbage...
deleting '/nix/store/trash'
deleting unused links...
note: currently hard linking saves 0.00 MiB
0 store paths deleted, 0.00 MiB freed
$ nix-shell
these paths will be fetched (0.51 MiB download, 2.47 MiB unpacked):
  /nix/store/09qfwy98lb4k6nkjvqzap6vz3xw2qbfx-bash-interactive-4.4-p23-dev
  /nix/store/7v4vy0c9s6w372fff8z6cg9940l1r9vc-bash-interactive-4.4-p23-info
  /nix/store/8rffdnidm329k1gjl237cyqxjm9lqk49-bash-interactive-4.4-p23-doc
  /nix/store/rp1my891q8x3kvd8gqwlb5fk2f13dn68-bash-interactive-4.4-p23-man
copying path '/nix/store/8rffdnidm329k1gjl237cyqxjm9lqk49-bash-interactive-4.4-p23-doc' from 'https://cache.nixos.org'...
copying path '/nix/store/09qfwy98lb4k6nkjvqzap6vz3xw2qbfx-bash-interactive-4.4-p23-dev' from 'https://cache.nixos.org'...
copying path '/nix/store/7v4vy0c9s6w372fff8z6cg9940l1r9vc-bash-interactive-4.4-p23-info' from 'https://cache.nixos.org'...
copying path '/nix/store/rp1my891q8x3kvd8gqwlb5fk2f13dn68-bash-interactive-4.4-p23-man' from 'https://cache.nixos.org'...

Even though I saved all the dependencies of my shell from being garbage collected, I didn’t save bashInteractive, because bashInteractive is not a dependency of my derivation. It’s something that’s hardcoded into nix-shell itself.

But by setting NIX_BUILD_SHELL explicitly, I no longer have this issue. Not because that has an explicit dependency on bashInteractive – that’s not strictly necessary – but just because the implicit dependency is gone. So: yay! A fun side effect.

Alright. I am happy, for now. My nix-shell works the way I want it to; there is no more weird context switch between “regular programming” and “programming in a nix-shell.”

It’s… not great that I had to think about this in first place, though. I suspect it wouldn’t be too hard to patch nix-shell to just transparently use whatever $SHELL it was invoked under. Sure, maybe not in --pure mode, but for “regular” development, it would take nix-shell from being “really useful but also really annoying” to just “really useful.” It would make it a little easier to recommend it.

Anyway this is the part at the end of a post where I ramble hoping to come up with some sort of pithy concluding remarks and eventually just cut it off abruptly and hit publish.