How to run .NET tools without the SDK

I have renamed ndnx to ndx.

.NET 10 shipped dnx (dotnet tool exec): one-shot nuget tools, npx-style. You don’t install them. You just run dnx stop and it downloads into the nuget cache and starts.

NOTE: I’m using stop as the sample Native AOT tool. It sends SIGINT (Ctrl+C) to a process in a cross-platform way — same as hitting Ctrl+C, not Stop-Process / kill -9. The RID-specific packages are native binaries, so they run with no .NET installed at all.

The catch: dnx is an SDK command. There is even an issue to ship it with the runtime. Until that happens (if it happens), you pay for the whole SDK just to start a tool. And even when the nupkg is already in the cache, a cached dnx stop@2.1.0 is still ~500ms. That’s the SDK tax.

This gets more annoying once tools are RID-specific Native AOT. Starting a native exe through the SDK is kinda the opposite of the point.

So I built ndx. Same CLI. Native AOT itself. No SDK.

TL;DR;

macOS / Linux:

curl -fsSL https://github.com/devlooped/ndx/releases/latest/download/install.sh | sh

Windows (PowerShell):

irm https://github.com/devlooped/ndx/releases/latest/download/install.ps1 | iex

Then:

ndx stop --help

If you can dnx stop, you can ndx stop:

dnx  stop --help
ndx stop --help

dnx  stop@2.1.0 --help
ndx stop@2.1.0 --help

dnx  dotnetsay@1.0.0 Hello
ndx dotnetsay@1.0.0 Hello

You don’t need -- before the tool args. Things Just Work.

stop is Native AOT. So is winget (Scott’s winget TUI, Windows-only). dotnetsay is a classic framework-dependent tool. All of them are just nuget packages. ndx picks the host RID and starts Runner=executable binaries directly, or dotnet exec for Runner=dotnet.

A first run downloads into the nuget cache. A later ndx tool@version starts the cached binary. No SDK. No restore.

It is not a nuget global tool on purpose. Installing the thing that lets you skip the SDK with dnx would be a pretty stupid chicken-and-egg.

Same cache as dnx

ndx uses the same nuget cache as dnx and dotnet restore. If you already ran dnx stop@2.1.0, ndx stop@2.1.0 just starts it. The other way around too.

Pin an exact version and a second run does not hit the feed. You also don’t pass a RID: ndx stop picks your OS/arch, same as dnx.

The flags are the dnx ones: --source, --add-source, --configfile, --version, --prerelease, --yes/-y, --allow-roll-forward, --verbosity, --disable-parallel, --ignore-failed-sources, --no-http-cache, --interactive.

Startup time

Cold is empty-cache download → start. Cached is start only (tool@version so neither runner resolves latest). linux-x64 (WSL2) is SDK 10.0.110; win-x64 is SDK 10.0.303. winget is win-x64 SDK 11.0.100-preview.

Tool Runner Cold linux-x64 Cold win-x64 Cached linux-x64 Cached win-x64
stop@2.1.0 (native AOT) dnx 2.2 s 2.1 s 580 ms 520 ms
  ndx 1.2 s 1.5 s 10 ms 34 ms
dotnetsay@1.0.0 (framework-dependent) dnx 1.6 s 1.8 s 570 ms 545 ms
  ndx 849 ms 1.1 s 12 ms 50 ms
winget (native AOT TUI, Windows) dnx 2.7 s 820 ms
  ndx 1.2 s 275 ms

Cold is the median of 3 empty-cache runs. Cached is the median of 10 runs after one seed. Isolated NUGET_PACKAGES. stop / dotnetsay from a private feed; winget from nuget.org. dnx is the SDK script (dotnet dnx).

winget is a TUI so it does not exit on its own. Cold is winget (latest), cached is winget@0.13.2. Each run waits until winget-tui is up, then ndx stop@2.1.0 signals that PID. The extra ~250ms vs stop is the TUI starting, not ndx.

10ms vs 580ms to start a native tool that is already on disk. That’s the number that made me write this.

Framework-dependent tools still need a runtime (dotnet on PATH or DOTNET_ROOT). Native ones don’t. ndx preflights Runner=dotnet against the muxer’s Microsoft.NETCore.App so you get a sane error instead of a host crash.

Floating versions stay current

I already wanted this in 2021 for long-running tools, which is how dotnet-evergreen happened. dnx doesn’t do it. ndx does, for free, when you don’t pin.

A floating version — unspecified, @*, @*-*, or a nuget range — stays current. ndx starts the latest match, watches the feed, downloads a newer matching version before stopping the child, then sends SIGINT / Ctrl+C (and WM_CLOSE if the tool is a GUI) and restarts. Short tools still exit as soon as the child exits.

Pin an exact version (tool@2.1.0) to disable that loop.

The poll interval defaults to 5 seconds and can be set in .netconfig:

[ndx]
    interval = 5

ndx walks from the working directory up, then ~/.netconfig. --verbosity quiet hides the Updating … line.

This is the interesting path for anything that stays up: MCP servers, agent harnesses, bots. Long-running, want the latest build, don’t want to bounce them yourself.

Update ndx itself

Self-update from GitHub Releases (optional version, including downgrades):

ndx --update
ndx --update 0.1.0

Learn more in the project repository.

Enjoy!

/kzu dev↻d