// one `with`, no ${} escape
Write raw shell inside Nix.
Shell, JavaScript, Python, and TypeScript inside Nix —
without escaping ${}.
Read from source, never evaluated. No preprocessor, no codegen; files stay valid .nix.
$ nix run .#deploy → /nix/store/…/bin/deploy
nixx stands on two pillars that need each other:
- Escape-light. One
with, and a${VAR}in a script body is the shell’s, not Nix’s — read from source, never escaped. No preprocessor, no codegen; files stay valid.nix, so nil/nixd never error. - Statically checked. That raw embedded shell would otherwise be opaque to
your tools.
shellintreads it back — running shellcheck on the body and guarding the one boundary the escape-light path leaves behind: the few${…}forms that do still need an''escape, and bare${VAR}that needs awith. Skip the escaping; let shellint point at whatever you missed.
{
packages = with inputs.nixx.lib.for pkgs; mkApps { packages = [ pkgs.rsync ]; } {
deploy = bash ''
echo "deploying from ${HOME}" # ${} is shell's — no ''${ } escape
rsync -a ./dist/ "$HOST:/srv/"
'';
};
}
nix run .#deploy # ships a /nix/store/.../bin/deploy
nix run nixx#shellint -- . # lint the embedded shell: boundary + shellcheck + env
Swap mkApps for mkTasks when you want a tab-completed tasks build /
tasks check workflow.
Add to your flake:
inputs.nixx.url = "github:nnao45/nixx";
# then, in a per-system output with `pkgs` in scope:
# { packages = with inputs.nixx.lib.for pkgs; mkApps { } { hello = bash ''echo hi''; }; }
Also speaks python (uv), typescript (bun/tsx), node, deno, perl, ruby, lua — bundles dev workflows with
mkTasks, and runs those same bodies as hermetic sandbox tests withmkTests(below). Full reference, dependency wiring, and every option table in API.md.
Pillar 1 — ${} belongs to the language
Every body passed as an attr value to mkApps, mkTasks, or mkScripts is read
from source instead of evaluated, so a ${VAR} in the ${} family — shell
${HOME}, a JS template `${x}`, a perl ${name} — survives verbatim with
no '' prefix. The one line of ceremony is the with
(inputs.nixx.lib.for pkgs — the canonical entry point): any with makes the
scope dynamic, deferring Nix’s static undefined-variable check to a runtime that
never arrives (the body is never forced). To splice in an actual Nix value,
use the @nix(x) / @sh:q(x) markers — native Nix ${…} does not run in a
source-read body, by design: ${} belongs to the language.
The common shell forms work with zero prefix. A few aren’t valid Nix inside
${…}, so Nix rejects them at parse time and they still need the '':
| form | zero prefix? | example |
|---|---|---|
${VAR}, $VAR, $@, $? | ✅ | echo ${HOME} |
${VAR:-d} ${VAR:=d} ${VAR:?e} ${VAR:+x} and the colon-less ${VAR-d} ${VAR+x} ${VAR?e} | ✅ | echo ${EDITOR:-vi} |
${VAR:off:len} (substring) | ✅ | echo ${name:0:3} |
${!ref} (indirect) ${ARR[0]} / ${ARR[i]} (numeric or variable index) | ✅ | echo ${ARR[i]} |
${VAR/old/new} ${VAR//old/new} (operands identifier-only) | ✅ | echo ${PATH//bin/BIN} |
${ARR[@]} ${#VAR} ${VAR%x} ${VAR#x} ${VAR^^} ${VAR,,} ${ARR[-1]} | ❌ use '' | for x in ''${ARR[@]}; do … |
${VAR/o/n} whose operand carries , ; # % etc. | ❌ use '' | ''${csv/,/;} |
The escape-light path is partial by design — and that’s exactly the gap
Pillar 2 closes: forget an '' on a ❌ row, or write a bare ${VAR} with no
with, and shellint points at the precise file:line instead of leaving you
a cryptic Nix parse error. (Mechanism — lazy thunks, unsafeGetAttrPos, the
parse-wall rule — in API.md.)
Hate even that? The ❌ rows exist because Nix’s parser rejects those forms
inside a string (# opens a comment, [@]/%/^ aren’t operators) — it’s the
Nix grammar, not nixx. The escape hatch is rawsh: put the body in #|
line-comments, which Nix never parses, so every parse-wall form survives with
zero escaping (and no with):
process = rawsh;
#| for f in ${FILES[@]}; do echo "${#f}: ${f^^} ${f%.txt}"; done
The trade is the body is a comment, so editors/shellint don’t see it as shell.
Reach for it only when a block is dense with ❌ forms; keep bash ''…'' (lint +
highlighting) for everything else.
Trade-off. Any
withdefers undefined-variable checking, so a typo in never-evaluated code under its scope won’t be caught statically — which is the other reasonshellintexists. Keep thewithon the flake output that builds your tasks; evaluated code still errors clearly at runtime.
Pillar 2 — shellint: check the embedded shell
shellint is a source-driven linter (no eval): it parses your .nix files
with tree-sitter-nix, finds every bash ''…'' block, and runs three passes that
a generic Nix linter can’t — because only nixx knows the string is shell:
- nix-boundary (fatal) — the warden of Pillar 1’s gap. A shell-only
expansion that breaks Nix (
${#x},${x[@]},${x^^}…) needs''${…}; a bare${VAR}with no enclosingwithwill fail Nix eval. Escaped''${…},with-scoped${VAR}, and real Nix interpolations (${pkgs.hello}) are left alone. tree-sitter-nix pinpoints the breakage even through parse cascades. - shellcheck (fatal) — the bash body is shellcheck’d (
$out/$src-style build-env refs excluded; add codes withexcludeShellChecks). - env (warn) — lists the external env each block requires (block-bound names subtracted).
nix run nixx#shellint -- ./ # lint a tree (or files)
nix run nixx#shellint -- --fix ./ # auto-fix the boundary: add/remove '' as needed
nix run nixx#shellint -- --fix --dry-run ./ # preview the fixes as a diff
--fix is the bidirectional normalizer for the boundary: it escapes a
shell-only ${…} that needs it (${#x} → ''${#x}) and de-escapes an ''${VAR}
that doesn’t (back to ${VAR}, only under a with). Each fixed file is re-parsed
afterward and reverted if it isn’t clean — so a fix can never leave you worse off.
# gate it in your own flake
checks.shellint = (inputs.nixx.lib.for pkgs).shellint {
src = ./.;
exclude = [ "*/vendor/*" ];
passes = { envcheck = false; }; # toggle individual passes
};
Full pass semantics, config, and the runtime sibling envCheck (a mkTasks
option that blocks a task when a required env var is unset) are in
API.md.
Per-block options
Call a block like a function with an attrset of options. This one idiom
covers both mkApps (language opts like compile) and mkTasks (task opts like
deps / env / cwd) — there is no separate app / task wrapper:
with inputs.nixx.lib.for pkgs;
mkApps { packages = [ pkgs.curl pkgs.jq ]; } { # ← packages is global (whole set)
fetch = bash ''curl -s https://api.example.com | jq .''; # ← no opts needed
report = uv ''
from rich import print
print("[green]ok[/]")
'' { requirements = [ "rich>=13" ]; }; # ← per-block
check = bun ''
const r: { ok: boolean } = { ok: true };
console.log(`status: ${r.ok}`);
'' { compile = true; }; # ← per-block
}
One rule: packages is global (first attrset of mkApps / mkTasks); the
language options are per-block. Passing packages per-block throws, on purpose.
| option | level | what it does |
|---|---|---|
packages | global | /bin on PATH for every app/task |
inputsFrom | global (mkTasks) | other derivations’ setup hooks + build inputs in the dev shell (tools that need a stdenv hook, not just a binary) |
requirements | per-block (uv) | PEP 723 inline deps |
compile | per-block (bun) | bun --compile → standalone binary |
projectRoot | per-block (uv/bun) | deps from ./pyproject.toml / package.json |
envCheck | global or per-block (bash) | block a task when a required env var is unset/empty; --env-list <task> just prints what it needs. Full semantics → API.md |
The same call form attaches task options in mkTasks
(bash ''…'' { deps = [ … ]; env = { … }; cwd = ./d; }). Full option matrix,
mkScript(s), and vars markers are in API.md.
Task runner
mkTasks is a just-style runner: one tasks <name> is a single bash
process, so an export (or defaultDeps/env) in an early task persists into
every later one. Only env crosses task boundaries — cwd and shell options
reset at each task’s entry (every bash task is set -euo pipefail; a dep’s cd
or set +u can’t leak), so tasks stay predictable. Supports deps, group,
per-task cwd / env.
$ tasks --list
build Build the project
test Run the test suite
release:
deploy Deploy production
$ tasks build # or: nix run .#tasks -- build
One trap: if a task calls a tool, put it in mkTasks { packages }, never only
in mkShell — a mkShell-only package is absent from nix run .#tasks. Full
env/deps semantics and the writers.mkTasks return value (runner /
devShell / extendShell) in API.md.
Hermetic tests — mkTests
mkTests runs each test in the Nix build sandbox: no $HOME, no network, and
PATH limited to the suite’s declared packages. A green run isn’t “passed on my
machine” — it’s “passed with only what was declared, offline, against a freshly
minted $HOME.” A hidden curl, a stray ~/.aws/credentials read, or an
undeclared jq can’t sneak a pass: the sandbox withholds them and the test fails
the instant it reaches for one. That guarantee is what a host-resident runner like
bats can’t give you.
Bodies are the usual source-read bash ''…'' (escape-light, shellcheck’d), with a
bats-compatible vocabulary so existing muscle memory transfers — over a
per-test writable $WORK the harness mints and tears down:
with inputs.nixx.lib.for pkgs;
{
# one mkTests call = one suite = one named check
checks.${system}.tools = mkTests { name = "tools"; packages = [ pkgs.jq ]; } {
setup = bash ''mkdir -p "$WORK/out"''; # lifecycle hook, not a test
"jq extracts fields" = bash ''
run jq -n '{ port: 8080 }' # run captures $status/$output
assert_success
assert_json '.port' '8080'
'';
"writes into the per-test $WORK" = bash ''
printf 'ok' > "$WORK/out/marker"
assert_file "$WORK/out/marker"
assert_file_contains "$WORK/out/marker" ok
'';
};
}
run captures $status / $output / $stderr (bats style); assert_success,
assert_failure, assert_output [--partial|--regexp], refute_output, and
assert_equal are joined by nixx extras assert_file, assert_dir,
assert_file_contains, and assert_json. setup / teardown / setup_suite /
teardown_suite are lifecycle hooks. The code under test is reached by name if
it’s a nixx app, or via src = ./.; (mounted read-only as $FIXTURES).
Two lanes from one definition:
| lane | how | when |
|---|---|---|
| hermetic | the returned derivation → checks.<system>; nix flake check builds it in the sandbox | CI, pre-commit — the source of truth |
| fast | .fast — a devShell-lane runnable, no sandbox, instant | the carve-carve TDD loop |
nixx test (shipped as a flake app: nix run nixx#test -- …) discovers
*_test.nix and drives both lanes:
nix run nixx#test # fast lane: every *_test.nix under .
nix run nixx#test -- -f deploy # only tests whose name contains "deploy"
nix run nixx#test -- --hermetic # run each suite in the sandbox
nix flake check # the hermetic suites, as checks
When a hermetic test goes red, --repro drops you into that test’s exact
environment — same $WORK, same PATH, setup already run, every run/assert_*
helper loaded — as an interactive shell; type t to re-run the body and watch the
assertion fail live:
nix run nixx#test -- path/to/x_test.nix --repro "jq extracts fields"
# cwd is $WORK (writable) · helpers loaded · `t` runs the body · Ctrl-D leaves
--once reads stdin one-shot instead of opening a prompt, so the repro path is
scriptable and CI-able.
Eating its own shell. The assert runtime and
nixx testCLI are written as source-readbash ''…''inside nixx itself — no external.sh. Runnable suites live intests/*_test.nix; the full option/assert reference is in API.md.
Process groups with process-compose
Use processCompose when the workflow is several long-running processes instead
of a dependency-ordered task list. It turns nixx bash blocks into a
process-compose config with readiness gates, depends_on, restart policy, and
graceful shutdown:
with inputs.nixx.lib.for pkgs;
let
pc = processCompose {
name = "dev";
vars = { port = 3000; };
"no-server" = true;
} {
web = bash ''
echo "web on @nix(port), home=${HOME}"
sleep 30
'' { readiness = { exec = "true"; initial_delay_seconds = 2; }; };
api = bash ''
echo "api after web"
sleep 30
'' { depends_on = [ "web" ]; restart = "on_failure"; };
};
in {
packages.default = pc.runner; # nix run .#default
}
Per-process options use the same bash ''...'' { ... } call form as tasks:
cwd, env, depends_on, readiness, restart, description, namespace,
and shutdown. pc.config, pc.configJson, and pc.configFile expose the
generated process-compose config for inspection or reuse. Runner options also
expose process-compose global flags like "no-server", "use-uds", and port.
Full mapping and return values are in API.md; the runnable
example is examples/process-compose.
Dev shells — pick your idiom
Same with inputs.nixx.lib.for pkgs;, same zero-${}-tax bodies; only the wiring
differs. The zero-config path lands the runner on PATH, tab-completed:
with inputs.nixx.lib.for pkgs;
let
apps = mkApps { } { whereami = bash ''echo ${PWD} as ${USER}''; };
tasks = mkTasks { name = "tasks"; } { info = bash ''echo ${PWD} as ${USER}''; };
in {
packages = apps // { default = tasks.runner; tasks = tasks.runner; };
devShells.default = tasks.devShell;
}
tasks.extendShell folds the runner into your own pkgs.mkShell (preserving its
env + shellHook); shellHook / runCommand wrappers give the same ${}-tax-free
bodies to plain Nix APIs; and devenv is supported by feeding a body’s .text into
enterShell. Runnable flakes for all three live in examples/; the wiring is
documented in API.md.
More
- Docs site: nnao45.github.io/nixx — browsable reference with examples.
- LLM-friendly plain-text: nnao45.github.io/nixx/llms.txt
- Multi-language & shippable binaries,
mkScript(s),varsmarkers (@nix,@sh:q), full language/option tables,shellint&envCheckreference,processCompose, and the linter source-mapping: API.md.