Fully automated hands-off PowerShell scripts can be extermely useful for the DBA or System Administrator, but what if you need to get input from the user, or maybe you want to implement a menu system? Like most things related to PowerShell, you have a few options:

Most use cases are covered by Read-Host, but if you need something a little more flexible, the [Console] methods might be the way to go.

Read-Host

Read-Host is a built-in cmdlet that blocks the current process waiting for user input followed by a press of the enter key. This can useful if you need to get something like a file path from a user. Here is a pattern I use often:

$MaxRetry = 5
$CurrentAttempt = 0

# Get a valid path from a user
While ( $CurrentAttempt -lt $MaxRetry -and -not $UserInput) {
    $UserInput = Read-Host -Prompt "Please enter a valid file path"
    if ( -not (Test-Path -Path $UserInput) ) {
        Write-Host "File path $($UserInput) not found, please try again."
        $CurrentAttempt += 1
        $UserInput = $False
    }
}

if ( -not $UserInput ) {
    Write-Error -Message "Valid file path not entered." -ErrorAction Stop
}

Read-Host is simple to use, it only has two parameters: -Prompt and -AsSecureString. The first is used to set the user prompt, the second to capture a password or other sensitive data (outputing it as a SecureString object). If you are a formatting nut like me, it's important to know that Read-Host slaps a : on the end of the prompt. This always bothers me, because I like total control over output whenever possible. For example:

PS> Read-Host -Prompt "How are you feeling today?"
How are you feeling today?:

So as you can see, Read-Host is very straight forward. It just reads user input and then outputs it when the user presses enter.

[Console]

[Console] has a few methods available around keyboard input that make it a lot more flexible and give you a lot more control. The methods we are going to focus on are:

ReadLine

First let's look at ReadLine. The docs are pretty great on this method, giving quite a few good examples of how it's used: Console.ReadLine Method. Note that the docs are not PowerShell specific, as [Console] is a .Net object, and not something specifically built for PowerShell.

Let's see how our above example would look using [Console]::ReadLine()

$MaxRetry = 5
$CurrentAttempt = 0

# Get a valid path from a user
While ( $CurrentAttempt -lt $MaxRetry -and -not $UserInput) {
    Write-Host "Please enter a valid file path: " -NoNewline
    $UserInput = [Console]::Readline()
    if ( -not (Test-Path -Path $UserInput) ) {
        Write-Host "File path $($UserInput) not found, please try again."
        $CurrentAttempt += 1
        $UserInput = $False
    }
}

if ( -not $UserInput ) {
    Write-Error -Message "Valid file path not entered." -ErrorAction Stop
}

So this looks about the same as before with two small differences. First, we are using Write-Host to manually display the prompt (and using -NoNewline to make it feel more like a prompt). This gives us a lot more control over the formatting and even the color of the prompt we display, which is a win in my book. Second, we have simply replaced Read-Host with [Console]::Readline() which will accept user input until the user presses enter.

ReadKey

Now let's check out [Console]::ReadKey(). In this case we are going to ask the user to "Press any key to continue..." to keep it simple:

Write-Host "Press any key to continue..."
$null = [Console]::ReadKey('NoEcho')

By default, ReadKey will echo the users keystroke back to the console, we can supress this via the 'NoEcho' option. It will also return information about the keypress which we are just tossing into $null since we don't need them. This example isn't very exciting, but you can already see that using this method gives you control you simply don't have using Read-Host.

To make things a bit more useful, let's take a closer look at the output of ReadKey(). In this quick snippet I am just going to press Shift + M:

PS> [Console]::ReadKey()
M
KeyChar Key Modifiers
------- --- ---------
    M   M     Shift

First, it shows the character I typed (an uppercase M), if I would have added 'NoEcho' this would have been supressed. Then it outputs an object that contains:

This is pretty cool as it would allow us to create menu systems with case sensitivity, or even implement special shortcut combos in a more interactive script.

A Simple Menu

Let's look at a more complicated example. Let's say you want to display a menu of choices and you want the user to choose one using a single letter:

# First define some menu options you want to display
# The underscore highlights the character the user would type
$Choices = @(
    "_Continue"
    "_Abort"
    "_Burn it all down"
)

$MenuChoices = @()

Foreach ( $Choice in $Choices ) {
    # This finds the underscore and finds the following character
    # so we can use it in our menu
    $ChoiceCharacter = $Choice -replace '.*_(.).*','$1'
    $MenuChoices += @{
        'Key' = $ChoiceCharacter
        'MenuItem' = "[$($ChoiceCharacter)]$($Choice -replace '_.','')"
    }
}

$UserMenuItem = $null
While ( -not $UserMenuItem ) {
    [Console]::Clear()
    Write-Host "------------------------"
    Write-Host " My Rad Menu v0.1"
    Write-Host "------------------------"
    # Display our menu
    Foreach ( $Item in $MenuChoices ) {
        Write-Host " $($Item.MenuItem)"
    }
    Write-Host "------------------------"
    # Display a prompt and wait for user input
    Write-Host "Pick an option: " -NoNewline
    $UserChoice = [Console]::ReadKey()

    # See if the user picked a valid item
    if ( $UserChoice.KeyChar -notin $MenuChoices.Key ) {
        Write-Host "`nInvalid choice, try again. Press any key to continue..."
        $null = [Console]::ReadKey('NoEcho')
    }

    # Get the menu item that corresponds to the user choice
    $UserMenuItem = $MenuChoices | Where-Object {
        $_.Key -eq $UserChoice.Keychar
    }
}

# We display the users choice, inserting a newline to make up for the -NoNewLine above
Write-Host "`nYou chose $($UserMenuItem.MenuItem)"

This one is a bit more complicated, but let's see what's happening:

I am using a little RegEx magic here to take a menu choice like "_Continue" and break it into the character the user will specify to pick that option "C" (it will pick whatever character is after the underscore), and a menu item to display in the menu "[C]ontinue". If the choice was "Abort _Session", it would be displayed as "Abort [S]ession" and the 'S' would be used to select this menu item. This is a great way to create quick menus without a ton of code.

This code could be extended to do almost anything you want. At the end we have the users choice and could easily build all sorts of logic around what to do next.

Now lets take this even further and introduce a the KeyAvailable property of [Console]. Like before, let's start with a simple example to see what it does:

While ( -not [Console]::KeyAvailable ) {
    Start-Sleep -Milliseconds 15
}

Write-Host "A key was pressed!"

This will sit in a loop until any key is pressed. It doesn't consume that keypress or do anything to process the keypress at all, it simply knows a key has been pressed and there is input in the input buffer that could be processed.

So what would we use this for? This is a fantastic tool when creating interactive monitoring scripts. Using this property we can sit in a loop, displaying changing information to user until they make a choice of some kind. Let's take a look at a simple example:

While ( -not [Console]::KeyAvailable ) {
    [Console]::Clear()
    Get-Process | Sort-Object -Property CPU -Descending | Select-Object -First 10 | Out-String
    Start-Sleep -Milliseconds 2000
}

Here we are displaying the top 10 processes by CPU, and refreshing every 2 seconds until the user presses a key. We can combine this with our menu code above to create some very powerful scripts. For example, maybe we want this script to give the user an option to filter on process name, or alter the refresh period:

$Choices = @(
    "_Filter Process Name",
    "_+ Increase Refresh Duration"
    "_- Decrease Refresh Duration"
    "E_xit"
)

$MenuChoices = @()

Foreach ( $Choice in $Choices ) {
    $ChoiceCharacter = $Choice -replace '.*_(.).*','$1'
    $MenuChoices += @{
        'Key' = $ChoiceCharacter
        'MenuItem' = $Choice.Replace("_$($ChoiceCharacter)","[$ChoiceCharacter]")
    }
}

$DurationMS = 2000
$ProcessFilter = ""
$Run = $true
$PParams = @{}

While ( $Run ) {
    # Clear the console
    [Console]::Clear()
    # Display process info
    Get-Process @PParams | Sort-Object -Property CPU -Descending | Select-Object -First 10 | Out-String
    # Display a single-line prompt
    Write-Host "$($MenuChoices.MenuItem-join(', ')) [$($DurationMS)ms]: " -NoNewline

    # Now lets sit in a loop and wait for input until the refresh duration passes or the user picks something
    $StartWait = Get-Date
    $Wait = $true
    While ( ((Get-Date) - $StartWait).TotalMilliseconds -le $DurationMS -and $Wait) {
        if ( [Console]::KeyAvailable ) {
            $UserChoice = [Console]::ReadKey()

            if ( $UserChoice.KeyChar -in $MenuChoices.Key ) {
                switch ($UserChoice.KeyChar) {
                    F { 
                        # Get the process to filter by
                        Write-Host "`nProcess Filter: "
                        $PFilter = [Console]::ReadLine()

                        if ( $PFilter ) { 
                            $PParams.Name = "*$($PFilter)*"
                        } else {
                            # Remove the name filter if the choice is blank
                            $PParams.Remove("Name")
                        }

                        $Wait = $False
                    }
                    - {
                        $DurationMS -= 500
                        $Wait = $False
                    }
                    + {
                        $DurationMS += 500
                        $Wait = $False
                    }
                    X {
                        $Wait = $False
                        $Run = $False
                        Write-Host "Exitting..." -ForegroundColor Red
                    }
                }
            } else {
                Write-Host "Invalid Choice!" 
                Start-Sleep -Milliseconds 500
                # Break the loop
                $Wait = $False
            }
        }
        Start-Sleep -Milliseconds 10
    }
}

This example is a lot to go through line by line, but it just combines all the things we've already discussed. In general though, when this is executed:

TIP: When writing scripts like this, make sure you draw the output, then sit in a tight loop listening for user input. This makes the script much more responsive. If we instead waited for the full refresh duration, $DurationMS above, the script would appear slow and unresponsive

Give this script a try and attempt to add your own changes and new options, it's the best way to learn!

Conclusion

Most folks think of PowerShell as a simple automation language, but you can do a LOT more if you use a little imagination. While I tend to shy away from using .Net objects/methods in my PowerShell code in favor of the native cmdlets, you can't deny the power and flexibility of .Net for the use case of user input. Thanks for reading!

NOTE: Watch this space, as I will be releasing a SQL Server process monitor (similar to a sp_WhoIsActive) using the methods discussed in this post in the coming months.