How to run .NET tools without the SDK

.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 ndnx. Same CLI. Native AOT itself. No SDK.

TL;DR;

macOS / Linux:

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

Windows (PowerShell):

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

Then:

ndnx stop --help

If you can dnx stop, you can ndnx stop:

dnx  stop --help
ndnx stop --help

dnx  stop@2.1.0 --help
ndnx stop@2.1.0 --help

dnx  dotnetsay@1.0.0 Hello
ndnx 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. ndnx 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 ndnx 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

ndnx uses the same nuget cache as dnx and dotnet restore. If you already ran dnx stop@2.1.0, ndnx 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: ndnx 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
  ndnx 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
  ndnx 849 ms 1.1 s 12 ms 50 ms
winget (native AOT TUI, Windows) dnx 2.7 s 820 ms
  ndnx 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 ndnx stop@2.1.0 signals that PID. The extra ~250ms vs stop is the TUI starting, not ndnx.

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. ndnx 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. ndnx does, for free, when you don’t pin.

A floating version — unspecified, @*, @*-*, or a nuget range — stays current. ndnx 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:

[ndnx]
    interval = 5

ndnx 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 ndnx itself

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

ndnx --update
ndnx --update 0.1.0

Learn more in the project repository.

Enjoy!

/kzu dev↻d