I'VE GOT THE BYTE ON MY SIDE

57005 or alive

How to capture or redirect Write-Host output in powershell

Apr 26, 2012 powershell

Write-Host, unlike most cmdlets, does not return anything to the pipeline.  It merely prints your object or message to the console.  That makes it the go-to cmdlet for the ever-so-common task of tracing out status messages.  Indeed, its defining feature is that it won’t screw up the return values from functions or scripts, since it emits nothing extra to the pipeline.  Unintentionally emitting trace messages (or just stuff in general) to the pipeline is a classic powershell n00b bug, so having such a cmdlet to do the task properly is key.

But what if you actually WANT to process those Write-Host messages?  e.g. redirect them to a file, filter them, parse them, etc?  Then you are out of luck, since the whole point of the cmdlet is to only print stuff, not return it to the pipeline.

Binging/Googling for a workaround to this problem only leads to a few not-so-great solutions:

  1. Don’t use Write-Host, use Write-Output  (this is good advice but not particularly helpful if you don’t own the code)
  2. Use Start-Transcript (hmm, sort of)
  3. One guy on StackOverflow who suggest using a proxy function for Write-Host (but doesn’t go into the details)

Solution #3 actually is a great option, but the guy does not provide any pointers on how to do it.  Here’s my implementation.

The approach is to use cmdlet proxies to create a custom version of Write-Host, which will effectively hide the “real” cmdlet.  Functions or scripts which call Write-Host will end up calling our version, and we can implement whatever behavior we want.  In particular, we will send input to the pipeline rather than simply print it (though we can optionally still print it, too).

We can package this into a single function Select-WriteHost, and support two basic usage patterns:

# "scriptblock" usage - capture output from the specified scriptblock
$output = Select-WriteHost { .\MyScriptWhichUsesWriteHost.ps1 }

# "inline" usage - capture and pass along output in the current pipeline
$output = .MyScriptWhichUsesWriteHost.ps1 | Select-WriteHost

Below is the code:

function Select-WriteHost
{
   [CmdletBinding(DefaultParameterSetName = 'FromPipeline')]
   param(
     [Parameter(ValueFromPipeline = $true, ParameterSetName = 'FromPipeline')]
     [object] $InputObject,

     [Parameter(Mandatory = $true, ParameterSetName = 'FromScriptblock', Position = 0)]
     [ScriptBlock] $ScriptBlock,

     [switch] $Quiet
   )

   begin
   {
     function Cleanup
     {
       # clear out our proxy version of write-host
       remove-item function:write-host -ea 0
     }

     function ReplaceWriteHost([switch] $Quiet, [string] $Scope)
     {
         # create a proxy for write-host
         $metaData = New-Object System.Management.Automation.CommandMetaData (Get-Command 'Microsoft.PowerShell.Utility\Write-Host')
         $proxy = [System.Management.Automation.ProxyCommand]::create($metaData)

         # change its behavior
         $content = if($quiet)
                    {
                       # in quiet mode, whack the entire function body, simply pass input directly to the pipeline
                       $proxy -replace '(?s)\bbegin\b.+', '$Object' 
                    }
                    else
                    {
                       # in noisy mode, pass input to the pipeline, but allow real write-host to process as well
                       $proxy -replace '($steppablePipeline.Process)', '$Object; $1'
                    }  

         # load our version into the specified scope
         Invoke-Expression "function ${scope}:Write-Host { $content }"
     }

     Cleanup

     # if we are running at the end of a pipeline, need to immediately inject our version
     #    into global scope, so that everybody else in the pipeline uses it.
     #    This works great, but dangerous if we don't clean up properly.
     if($pscmdlet.ParameterSetName -eq 'FromPipeline')
     {
        ReplaceWriteHost -Quiet:$quiet -Scope 'global'
     }
   }

   process
   {
      # if a scriptblock was passed to us, then we can declare
      #   our version as local scope and let the runtime take it out
      #   of scope for us.  Much safer, but it won't work in the pipeline scenario.
      #   The scriptblock will inherit our version automatically as it's in a child scope.
      if($pscmdlet.ParameterSetName -eq 'FromScriptBlock')
      {
        . ReplaceWriteHost -Quiet:$quiet -Scope 'local'
        & $scriptblock
      }
      else
      {
         # in pipeline scenario, just pass input along
         $InputObject
      }
   }

   end
   {
      Cleanup
   }  
}

Some comments/warnings:

So, armed with our new function, we can now do stuff like this:

# assign to a variable, allow console printing to also happen
PS> $test = 'a','b','c' |%{ Write-Host $_ } | Select-WriteHost
a
b
c
PS> $test
a
b
c

# suppress output via -Quiet switch, and do some filtering
PS> $test = 1..10 |%{ Write-Host "Number is $_" } | Select-WriteHost -Quiet |?{$_ -like "Number is 1*"}
PS> $test
Number is 1
Number is 10

# save transcript of script execution to a log
PS> Select-WriteHost { .\MyScript.ps1 } | Out-File .\ExecutionLog.log

And here’s how to see the potential issue with inline usage:

# look at default write-host command
PS> Get-Command Write-Host
Capability      Name                                               ModuleName
----------      ----                                               ----------
Cmdlet          Write-Host                                         Microsoft.PowerShell.Utility

# hit CTRL-C to abort execution midway through
PS> $test = 1..9999 |%{ Write-Host $_ } | Select-WriteHost

# now verify the proxy has polluted the environment since it wasn't cleaned up
PS> Get-Command Write-Host

Capability      Name                                               ModuleName
----------      ----                                               ----------
Cmdlet, Script  Write-Host

# try the same thing with scriptblock usage
PS> $test = Select-WriteHost { 1..9999 |%{ Write-Host $_ } }

# we see that the runtime cleans out the proxy for us
PS> Get-Command Write-Host
Capability      Name                                               ModuleName
----------      ----                                               ----------
Cmdlet          Write-Host                                         Microsoft.PowerShell.Utility

# as a safeguard, can always do cleanup manually
PS> Remove-Item function:\Write-Host -ea 0