Custom Package Definitions

Updated

September 12, 2026

Custom Package Definitions

Internal CLIs need a recipe, not a gist in $HOME. The boring default is: { lib, stdenv, … }: + pkgs.callPackage ./desk-tool.nix { }, store-path wrappers, and no hardcoded pkgs.curl inside the recipe.

Mental model

desk-tool.nix     function of dependencies
callPackage       fills args from pkgs
$out/bin/…        the artifact

callPackage ./f.nix { curl = otherCurl; } overrides one arg. That is how overlays and cross work.

Scripts: writeShellApplication (fundamentals) is enough for many desk tools. stdenv.mkDerivation when you compile. makeWrapper / wrapProgram when a script must see curl on PATH at runtime.

Worked examples

Case 1: writeShellApplication recipe

Save as desk-tool.nix:

# desk-tool.nix
{ writeShellApplication, curl, jq }:

writeShellApplication {
  name = "desk-tool";
  runtimeInputs = [ curl jq ];
  text = ''
    echo "Querying desk API with curl and jq..."
  '';
}

Save as default.nix:

# default.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.callPackage ./desk-tool.nix { }
nix-build default.nix
./result/bin/desk-tool

Output:

Querying desk API with curl and jq...

runtimeInputs puts curl and jq on PATH inside the wrapper. The host need not have them.

Case 2: stdenv + wrapProgram

Save as desk-tool-stdenv.nix:

# desk-tool-stdenv.nix
{ lib, stdenv, makeWrapper, curl, jq }:

stdenv.mkDerivation {
  pname = "desk-tool";
  version = "1.0.0";
  src = ./.;
  dontUnpack = true;
  nativeBuildInputs = [ makeWrapper ];
  installPhase = ''
    mkdir -p $out/bin
    echo '#!/bin/sh' > $out/bin/desk-tool
    echo 'echo desk-tool' >> $out/bin/desk-tool
    chmod +x $out/bin/desk-tool
    wrapProgram $out/bin/desk-tool \
      --prefix PATH : ${lib.makeBinPath [ curl jq ]}
  '';
}

Prefer Case 1 unless you already have an installPhase.

Case 3: Override an input

# default.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.callPackage ./desk-tool.nix {
  jq = pkgs.jq;
}

Passing jq explicitly is how you inject a patched jq from an overlay.

Case 4: Flake package

# flake.nix
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
    {
      packages.x86_64-linux.desk-tool = pkgs.callPackage ./desk-tool.nix { };
      packages.x86_64-linux.default = self.packages.x86_64-linux.desk-tool;
    };
}
nix build
./result/bin/desk-tool

Case 5: meta

meta = {
  description = "Internal desk operations utility";
  license = lib.licenses.mit;
  platforms = lib.platforms.unix;
};

nix search / nix flake show readers see this. Internal tools still get meta.

passthru.tests is how you attach a check without changing $out:

# desk-tool.nix fragment
passthru.tests.runs = pkgs.runCommand "desk-tool-runs" { } ''
  ${desk-tool}/bin/desk-tool >/dev/null
  touch $out
'';

Wire that into checks in the flake. meta.mainProgram = "desk-tool"; makes nix run .#desk-tool work without guessing the bin name.

The trap

The trap is pkgs.curl inside desk-tool.nix. Then callPackage cannot swap curl. Cross-compile and overlays lose. Arguments on the function, always.

The other trap is src = ./.; including .git and README (caching chapter). lib.fileset / cleanSource when the recipe grows.

The boring rule

  • Recipe is a function. callPackage injects.
  • writeShellApplication for scripts; stdenv for builds.
  • Runtime tools via runtimeInputs / wrapProgram, not the user’s PATH.
  • Flake packages.<system>.desk-tool on 26.05. meta.mainProgram for nix run.
  • passthru.tests for checks; do not hide tests only in CI YAML.
  • Override through callPackage ./f.nix { curl = …; }.

Try this

  1. Add hello to runtimeInputs and call hello from the script.
  2. callPackage ./desk-tool.nix { jq = pkgs.jq; } explicitly.
  3. nix path-info -Shr ./result and find curl in the closure.
  4. Break the shebang by using a raw echo > $out/bin without wrap; run on a PATH without curl; then restore the wrapper.