Custom Package Definitions
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-toolOutput:
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-toolCase 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.
callPackageinjects. writeShellApplicationfor scripts;stdenvfor builds.- Runtime tools via
runtimeInputs/wrapProgram, not the user’s PATH. - Flake
packages.<system>.desk-toolon 26.05.meta.mainProgramfornix run. passthru.testsfor checks; do not hide tests only in CI YAML.- Override through
callPackage ./f.nix { curl = …; }.
Try this
- Add
hellotoruntimeInputsand callhellofrom the script. callPackage ./desk-tool.nix { jq = pkgs.jq; }explicitly.nix path-info -Shr ./resultand find curl in the closure.- Break the shebang by using a raw
echo > $out/binwithout wrap; run on a PATH without curl; then restore the wrapper.