Comfortably using scripts in PowerShell

Over the years, I've found myself writing many small programs to help with my work. Most of these are in either C# or Python, depending on the task at hand. Because Windows doesn't natively support shebangs (the #! line at the top of a script), running these programs can be a bit cumbersome:

  • C#: At a minimum, you need a compiled DLL for your code, and you must remember to run dotnet every time. This implies you've already built the code, and you also have to keep track of the DLL path, update the DLL when changes occur, and so on.
  • Python: There are good reasons to avoid permanently adding Python to your PATH, so environment management becomes an issue—plus you still have to remember where Python is installed.

Fortunately, there are ways around these hassles:

  • In C#, you can use dotnet-script , which allows you to run C# files as scripts without compiling them first.
  • In Python, tools like uv  or pipx  let you run Python scripts without a global install. They also support inline script metadata to automatically manage virtual environments for you!

All of this is great, but it still doesn't solve the problem of having to run dotnet script, uv, pipx, or any other command each time you want to invoke a script—not to mention remembering the full paths.

I've found that adding the following snippet to my profile.ps1 helps a lot:

function Add-PythonScripts {
  Get-ChildItem -Path (Resolve-Path "~/.dev/python/*.py") | ForEach-Object {
    $scriptName = $_.BaseName
    $fullPath = $_.FullName
    Set-Item -Path "function:global:$scriptName" -Value {
      if (Get-Command "uv" -ErrorAction SilentlyContinue) {
        & uv run "$fullPath" @Args
      }
      elseif (Get-Command "pipx" -ErrorAction SilentlyContinue) {
        & pipx run "$fullPath" @Args
      }
      elseif (Get-Command "python" -ErrorAction SilentlyContinue) {
        & python "$fullPath" @Args
      }
    }.GetNewClosure()
  }
}

Add-PythonScripts

function Add-DotnetScripts {
  Get-ChildItem -Path (Resolve-Path "~/.dev/dotnet/*.csx") | ForEach-Object {
    $scriptName = $_.BaseName
    $fullPath = $_.FullName
    Set-Item -Path "function:global:$scriptName" -Value {
      & dotnet script "$fullPath" -- @Args
    }.GetNewClosure()
  }
}

Add-DotnetScripts

These two functions add any .py scripts from ~/.dev/python and .csx scripts from ~/.dev/dotnet as global functions in your PowerShell session. That way, you can call them just like any other command, and they'll automatically handle the arguments you pass along.

Here's what a Python script might look like:

#!/usr/bin/env python
# /// script
# dependencies = ["dep1", "dep2"]
# ///

if __name__ == '__main__':
    print('Hello world!')

And here's an example in C#:

#!/usr/bin/env dotnet-script
#r "nuget: NugetPkg1, 1.0.0"
#nullable enable

Console.WriteLine("Hello world!");

Because these scripts are exposed as native PowerShell functions, you also get PowerShell tab autocompletion on the names, and any other features you'd expect from normal commands.