Writing Nushell scripts to PATH using Nix and home-manager

Published

If you're familar with Home Manager using Nix, you likely have used writeShellApplication to write an executable Bash shell script to /nix/store/<store path>/bin/<name> and make it available to any new shell sessions.

I wanted something similar but for any Nushell scripts that I write. I still write Bash scripts if it's something really small or if I need the script to be highly portable to systems where I can't install anything. Since my interactive shell is Nushell, I sometimes end up creating something interactively that I want to keep around as a script for future use, but I don't want to have to rewrite it into Bash or python. Unfortunately there isn't a native 1:1 equivalent for Nushell such as writeNushellApplication, but with a little bit of tinkering I was able to build my own.

How writeShellApplication is implemented

Taking a look at the nixpkgs source to understand how writeShellApplication is implemented, I saw that it runs through the following steps:

  1. Write the interpreter shebang as the first line in the file.
  2. Add provided file content to the file after the shebang.
  3. Make the file executable using chmod +x.
  4. Update the PATH environment variable to include the path to the new file.
  5. Run shellcheck to lint the generated file.

For its part, home-manager creates a symlink from the built script in the store path to the user profile.

With those steps in mind, I took a look at what existed in the official docs and source code.

Using what Nixpkgs already has

Looking in the Nixpkgs Reference Manual and Home Manager Manual, I couldn't find anything specific about writing Nushell scripts.

However, looking through the files in https://github.com/NixOS/nixpkgs/pkgs/build-support, I did find writeNu and writeNuBin:

writeNu =
  name: argsOrScript:
    if lib.isAttrs argsOrScript && !lib.isDerivation argsOrScript then
      makeScriptWriter (
      argsOrScript // { interpreter = "${lib.getExe pkgs.nushell} --no-config-file"; }
    ) name
    else
      makeScriptWriter { interpreter = "${lib.getExe pkgs.nushell} --no-config-file"; } name argsOrScript;

writeNuBin = name: writeNu "/bin/${name}";

These functions are thin wrappers around makeScriptWriter. Calling writeNuBin will perform the following steps:

  1. Write the interpreter shebang as the first line in the file.
  2. Write the provided file content to the file after the shebang.
  3. Make the file executable using chmod +x.

The only steps that are missing then are adding the file to the PATH environment variable, and implementing a linting step.

Creating the initial wrapper

I started by creating a new writeNushellApplication function that calls writeNuBin and ensures that the generated script is added to the PATH. I was able to copy this almost verbatim from the comments on the writeNuBin function.

writeNushellApplication =
    {
      name,
      text,
      runtimeInputs ? [ ],
      makeWrapperArgs ? [ ],
    }:
    pkgs.writers.writeNuBin name {
      makeWrapperArgs =
        makeWrapperArgs
        ++ pkgs.lib.optionals (runtimeInputs != [ ]) [
          "--prefix"
          "PATH"
          ":"
          (pkgs.lib.makeBinPath runtimeInputs)
        ];
    } text;

Adding this to my home-manager config's let block allows me to use it exactly as I would writeShellApplication:

(writeNushellApplication {
  name = "dither";
  text = builtins.readFile ./scripts/dither.nu;
})

After rebuilding my home-manager config, dither is now a command that is symlinked under my profile (/etc/profiles/per-user/lucas/bin/), and it uses #!/nix/store/<omitted...>/nu --no-config-file as its shebang.

There's just one more step left.

Adding a lint check

makeScriptWriter provides support for a check hook, it's just unused by default. Since Nushell ships with a syntax checker, nu-check, I decided to use that to lint my scripts.

There's just one small wrinkle. makeScriptWriter calls the provided check using ${check} out, which means it expects a shell command that it can pass the built script into. But nu-check isn't a standalone executable, it's a command inside Nushell. So I needed to create a small wrapper that passes the script to nu -c "nu-check".

writeNushellApplication =
  {
    name,
    text,
    runtimeInputs ? [ ],
    makeWrapperArgs ? [ ],
    check ? true,
  }:
  pkgs.writers.writeNuBin name {
    makeWrapperArgs =
      makeWrapperArgs
      ++ pkgs.lib.optionals (runtimeInputs != [ ]) [
        "--prefix"
        "PATH"
        ":"
        (pkgs.lib.makeBinPath runtimeInputs)
      ];
    check = pkgs.lib.optionalString check (
      pkgs.writeShellScript "nu-check-wrapper" ''
        ${pkgs.lib.getExe pkgs.nushell} --no-config-file -c "nu-check --debug '$1'"
      ''
    );
  } text;

Now, if nu-check finds a syntax error, the build step will fail. By default all Nushell scripts will be linted at build time, but to disable the check for a specific script, I can just pass check = false; to the function.

(writeNushellApplication {
  name = "dither";
  text = builtins.readFile ./scripts/dither.nu;
  check = false;
})