Not too long ago I posted a function I wrote for doing PowerShell demonstrations. My goal was to simulate a live interactive demo but without the typing so I could focus on explaining and not typing. The first version was a good start but I always had plans for a more feature complete function including typos, support for multiline expressions and transcription. That's what I have for today's Friday Fun.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
As always, the download file includes comment based help.
Function Start-TypedDemo { [cmdletBinding(DefaultParameterSetName="Random")] Param( [Parameter(Position=0,Mandatory=$True,HelpMessage="Enter the name of a text file with your demo commands")] [ValidateScript({Test-Path $_})] [string]$File, [ValidateScript({$_ -gt 0})] [Parameter(ParameterSetName="Static")] [int]$Pause=100, [Parameter(ParameterSetName="Random")] [ValidateScript({$_ -gt 0})] [int]$RandomMinimum=50, [Parameter(ParameterSetName="Random")] [ValidateScript({$_ -gt 0})] [int]$RandomMaximum=200, [Parameter(ParameterSetName="Random")] [switch]$IncludeTypo, [string]$Transcript, [switch]$NoExecute, [switch]$NewSession ) #this is an internal function so I'm not worried about the name Function PauseIt { Param() #wait for a key press $Running=$true #keep looping until a key is pressed While ($Running) { if ($host.ui.RawUi.KeyAvailable) { $key = $host.ui.RawUI.ReadKey("NoEcho,IncludeKeyDown") if ($key) { $Running=$False #check the value and if it is q or ESC, then bail out if ($key -match "q|27") { Write-Host `r Return "quit" } } } start-sleep -millisecond 100 } #end While } #PauseIt function #abort if running in the ISE if ($host.name -match "PowerShell ISE") { Write-Warning "This will not work in the ISE. Use the PowerShell console host." Return } Clear-Host if ($NewSession) { #simulate a new session #define a set of coordinates $z= new-object System.Management.Automation.Host.Coordinates 0,0 $header=@" Windows PowerShell Copyright (C) 2009 Microsoft Corporation. All rights reserved. `r "@ Write-Host $header } #Start a transcript if requested $RunningTranscript=$False if ($Transcript) { Try { Start-Transcript -Path $Transcript -ErrorAction Stop | Out-Null $RunningTranscript=$True } Catch { Write-Warning "Could not start a transcript. One may already be running." } } #strip out all comments and blank lines #$commands=Get-Content -Path $file | Where {-NOT $_.StartsWith("#") -AND $_ -match "\w"} $commands=Get-Content -Path $file | Where {-NOT $_.StartsWith("#") -AND $_ -match "\w|::|{|}|\(|\)"} $count=0 #write a prompt using your current prompt function Write-Host $(prompt) -NoNewline $NoMultiLine=$True $StartMulti=$False #define a scriptblock to get typing interval $interval={ if ($pscmdlet.ParameterSetName -eq "Random") { #get a random pause interval Get-Random -Minimum $RandomMinimum -Maximum $RandomMaximum } else { #use the static pause value $Pause } } #end Interval scriptblock #typo scriptblock $Typo={ #an array of characters to use for typos $matrix="a","s","d","f","x","q","w","e","r","z","j","t","x","c","v","b" #Insert a random bad character Write-host "$($matrix | get-random) " -nonewline Start-Sleep -Milliseconds 500 #simulate backspace Write-host `b -NoNewline start-sleep -Milliseconds 300 Write-host `b -NoNewline start-sleep -Milliseconds 200 Write-Host $command[$i] -NoNewline } #end Typo Scriptblock #define a scriptblock to pause at a | character in case an explanation is needed $PipeCheck={ if ($command[$i] -eq "|") { If (PauseIt -eq "quit") {Return} } } #end PipeCheck scriptblock foreach ($command in $commands) { #trim off any spaces $command=$command.Trim() $count++ #pause until a key is pressed which will then process the next command if ($NoMultiLine) { If (PauseIt -eq "quit") {Return} } #SINGLE LINE COMMAND if ($command -ne "::" -AND $NoMultiLine) { for ($i=0;$i -lt $command.length;$i++) { #simulate errors if -IncludeTypo if ($IncludeTypo -AND ($(&$Interval) -ge ($RandomMaximum-5))) { &$Typo } #if includetypo else { #write the character write-host $command[$i] -NoNewline } #insert a pause to simulate typing Start-sleep -Milliseconds $(&$Interval) &$PipeCheck } #Pause until ready to run the command If (PauseIt -eq "quit") {Return} Write-host `r #execute the command unless -NoExecute was specified if (-NOT $NoExecute) { Invoke-Expression $command | Out-Default } } #IF SINGLE COMMAND #START MULTILINE #skip the :: elseif ($command -eq "::" -AND $NoMultiLine) { $NoMultiLine=$False $StartMulti=$True #define a variable to hold the multiline expression [string]$multi="" } #elseif #FIRST LINE OF MULTILINE elseif ($StartMulti) { for ($i=0;$i -lt $command.length;$i++) { if ($IncludeTypo -AND ($(&$Interval) -ge ($RandomMaximum-5))) { &$Typo } else { write-host $command[$i] -NoNewline} #else start-sleep -Milliseconds $(&$Interval) #only check for a pipe if we're not at the last character #because we're going to pause anyway if ($i -lt $command.length-1) { &$PipeCheck } } #for $StartMulti=$False #add the command to the multiline variable $multi+=$command If (PauseIt -eq "quit") {Return} } #elseif #END OF MULTILINE elseif ($command -eq "::" -AND !$NoMultiLine) { Write-host `r Write-Host ">> " -NoNewline $NoMultiLine=$True If (PauseIt -eq "quit") {Return} #execute the command unless -NoExecute was specified Write-Host `r if (-NOT $NoExecute) { Invoke-Expression $multi | Out-Default } } #elseif end of multiline #NESTED PROMPTS else { Write-Host `r Write-Host ">> " -NoNewLine If (PauseIt -eq "quit") {Return} for ($i=0;$i -lt $command.length;$i++) { if ($IncludeTypo -AND ($(&$Interval) -ge ($RandomMaximum-5))) { &$Typo } else { write-host $command[$i] -NoNewline } start-sleep -Milliseconds $(&$Interval) &$PipeCheck } #add the command to the multiline variable $multi+=$command } #else nested prompts #reset the prompt unless we've just done the last command if (($count -lt $commands.count) -AND ($NoMultiLine)) { Write-Host $(prompt) -NoNewline } } #foreach #stop a transcript if it is running if ($RunningTranscript) { #stop this transcript if it is running Stop-Transcript | out-Null } } #function
This version lets you use a static interval between characters or you can use a random interval. This interval is a random number of milliseconds between a minimum and maximum value. The function defaults to a random interval and the defaults, at least for me, seem short enough to keep the demo moving yet still give the illusion of typing. If you use the -IncludeTypo parameter, then every once in a while an errant character will be introduced, backspaced, and replaced. As such, I don't recommend using this parameter with the -Transcription parameter as you end up with control characters in the text file which makes it kind of ugly. Personally, I probably won't use the typo feature often but wanted to include it just in case.
This version also includes a parameter so that you can simulate a new PowerShell session. I use this when recording PowerShell content since I can cut out the command that starts the demo and have the video start at what looks like a new PowerShell session complete with the copyright notice.
if ($NewSession) { #simulate a new session #define a set of coordinates $z= new-object System.Management.Automation.Host.Coordinates 0,0 $header=@" Windows PowerShell Copyright (C) 2009 Microsoft Corporation. All rights reserved. `r "@ Write-Host $header }
The last major improvement is support for multiline commands. This was more involved than I expected. My solution is to include a marker (::) that indicates the beginning and end of a multiline command. Thus in your demo file, you would have something like this:
cd c:\
::
get-wmiobject win32_logicaldisk -filter "Drivetype=3" |
Select Caption,VolumeName,Size,Freespace |
format-table -autosize
::
gsv spooler
The function will parse the script and when it hits a ::, it knows the next line will be the first line of a multiline command. The lines after that are nested and the function inserts the necessary >> prompt. When it hits the next ::, it knows the end of the multiline has been reached and inserts the last >>. Pressing any key or Enter will execute it just like any other command.
The function takes advantage of parameter sets and uses scriptblocks as a modularization technique. I knew there would be places where I needed to repeat a section of code such as checking if the current character is a | so I can pause the script. So I defined this:
#define a scriptblock to pause at a | character in case an explanation is needed $PipeCheck={ if ($command[$i] -eq "|") { If (PauseIt -eq "quit") {Return} } } #end PipeCheck scriptblock
Now, instead of having all that code repeated, I can invoke the scriptblock.
for ($i=0;$i -lt $command.length;$i++) { if ($IncludeTypo -AND ($(&$Interval) -ge ($RandomMaximum-5))) { &$Typo } else { write-host $command[$i] -NoNewline } start-sleep -Milliseconds $(&$Interval) &$PipeCheck }
I did the same thing to handle the typo code ($Typo) and getting the sleep interval between characters ($Interval). Using the scriptblocks kept these sections shorter and if I found a bug say the typo code, I only had to change it one place.
So download it and try it out, especially if you do any sort of PowerShell presentations. Hopefully I've added enough comments so you can understand what is happening. If you have any comments, questions or suggestions, please let me know. In fact, if your organization is looking for some PowerShell related training of any sort, let me know via email and you'll most likely get to see this function in action first hand!
Download Start-TypedDemo-v2
Hi,
that’s really nice. There’s a problem with multi-line commands, however. Try this
get-something | ForEach-Object {
$foo = $_
do-something $foo
do-somethingelse $foo
}
When each line of the script block is being added to $multi a semicolon should be added, too. I’ve changed the script by adding
if (!$command.Endswith('{')) { $multi += ";" }
after both occasions where$multi+=$command
is called.Yeah…I came across that bug as well. I’ll be posting a updated version soon.
Another suggestion: If you change the multiline identifier “::” to “#::” the transcript could be run as normal powershell script, too, e.g. for testing. Updated script
I thought about finding a way to use a PowerShell -friendly solution. I rename the file to a txt so there’s no mistaking it as a PowerShell script. I also included a -NoExecute parameter to test. My updated version, also includes a tweak here. That said, I like the way you handled the #:: in your script.