Article summary
Every project benefits from lightweight CLI scripts that can reach into your application code — for data seeding, format verification, one-off exports, and other tasks that don’t deserve a full endpoint or test harness but need access to your real domain logic.
A few years ago, I wrote about adding CLI scripts to TypeScript/Node projects with TSX. I’ve wanted the same thing on C# projects for a long time and it’s been a frustrating gap. F# has had dotnet fsi for interactive scripting, and third-party tools like dotnet-script have tried to fill the role for C#. But there hasn’t been a first-class, built-in way to just write a .cs file, reference your project, and run it. So you end up creating throwaway console apps, stuffing things into your test suite, or — most often — just not writing the script at all.
.NET 10 finally changes this.
dotnet run file.cs
With file-based apps, you can now run a standalone .cs file directly:
dotnet run Scripts/FormatId.cs 12345678
No .csproj required. The file is a complete, self-contained program. Configuration that would normally live in a project file is handled through directives at the top of the file. You can import a NuGet package like this:
#:package [email protected]
Or, importantly, reference an existing project from your application:
#:project ../MyApp.Data/MyApp.Data.csproj
A Real Example
Say your application has an IdFormatter utility that formats internal integer IDs for display — adding a prefix, grouping digits, appending a check digit. The logic lives in your data layer, and you want a quick way to format or parse IDs from the command line without booting the whole application.
Here’s Scripts/FormatId.cs:
#:project ../MyApp.Data/MyApp.Data.csproj
using MyApp.Data.Utils;
if (args.Length < 2)
{
Console.WriteLine("Usage:");
Console.WriteLine(" dotnet run Scripts/FormatId.cs format ");
Console.WriteLine(" dotnet run Scripts/FormatId.cs parse ");
return 1;
}
var command = args[0].ToLower();
switch (command)
{
case "format":
if (!int.TryParse(args[1], out var id))
{
Console.Error.WriteLine($"Invalid ID: {args[1]}");
return 1;
}
Console.WriteLine(IdFormatter.Format(id));
return 0;
case "parse":
Console.WriteLine(IdFormatter.Parse(args[1]));
return 0;
default:
Console.Error.WriteLine($"Unknown command: {command}");
return 1;
}
It doesn’t even need a Class or public static void Main!
That #:project directive at the top gives the script access to IdFormatter — the same code your application uses in production. No duplication, no separate console app project, no compilation step.
If you’re using a task runner like Just it wires up nicely:
# format an internal ID for display
format-id value:
@dotnet run Scripts/FormatId.cs format {{value}}
# parse a formatted ID back to its integer value
parse-id formatted:
@dotnet run Scripts/FormatId.cs parse {{formatted}}
#️⃣❗️ These file-based apps also support shebang lines, so on Unix systems you can achieve shell invocation like
./format-id.cs. One caveat: if your project enforces Windows\r\nline endings (perhaps with a formatter like CSharpier), shell invocation will choke on them. In cross-platform codebases it may be easier to stick withdotnet runor a task runner.
Start a Scripts/ Folder
Script tooling like this allows you to cut a hole in the side of your application and directly reach the component you need. Next time you find yourself looking for a place to run some temporary exploratory code, stop before you add that throwaway log statement / endpoint / test! Consider dropping it in a CLI script instead.
By creating a home for scripts like these, you may find that some of these temporary exploratory tasks grow into shared reusable tools. That data migration you always do by hand? Script it. The ID format you keep looking up? Give it a CLI.
This capability has been valuable in interpreted languages for ages, and in .NET’s own F# for years. Now C# has a first-class answer, and it’s good. If you’re on .NET 10, start a Scripts/ folder.
An example project demonstrating this approach can be found on GitHub.