Skip to content

Instantly share code, notes, and snippets.

@mklement0
Last active October 15, 2023 20:14
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mklement0/880624fd665073bb439dfff5d71da886 to your computer and use it in GitHub Desktop.
Save mklement0/880624fd665073bb439dfff5d71da886 to your computer and use it in GitHub Desktop.
Wrapper PowerShell function for Get-Help that shows help topics online by default and supports copying URLs of / Markdown links to the online help topics to the clipboard
<#
Prerequisites: PowerShell v5.1 and above (verified; may also work in earlier versions)
License: MIT
Author: Michael Klement <mklement0@gmail.com>
DOWNLOAD and DEFINITION OF THE FUNCTION:
irm https://gist.github.com/mklement0/880624fd665073bb439dfff5d71da886/raw/Show-Help.ps1 | iex
The above directly defines the function below in your session and offers guidance for making it available in future
sessions too.
DOWNLOAD ONLY:
irm https://gist.github.com/mklement0/880624fd665073bb439dfff5d71da886/raw > Show-Help.ps1
The above downloads to the specified file, which you then need to dot-source to make the function available
in the current session:
. ./Show-Help.ps1
To learn what the function does:
* see the next comment block
* or, once downloaded and defined, invoke the function with -? or pass its name to Get-Help.
To define an ALIAS for the function, (also) add something like the following to your $PROFILE:
Set-Alias shh Show-Help
#>
function Show-Help {
<#
.SYNOPSIS
Wrapper command for the Get-Help cmdlet that shows help topics online rather
than locally in the console / terminal.
.DESCRIPTION
about_* topics are also supported, via direct construction of the target URL.
-CopyUrl (-cpu) / -CopyLink (-cp) copy the online help topic's URL to the
clipboard as-is / as a Markdown link instead of opening it in the browser.
For about_CommonParameters and about_Automatic_Variables specifically, you may
pass a specific parameter / variable name as the 2nd positional argument or
via -Anchor. Short parameter aliases such as 'wi' for 'WhatIf' are supported.
Additionally, 'where' and 'foreach' are supported for about_Arrays to look up
the .Where() and .ForEach() array methods.
As an alternative to online lookup you my specify -Local, in which case
local help content, with the default detail level changed to -Full, is captured
in a temporary file and displayed via your system's default text editor,
Run Get-Help Get-Help for help on the other parameters.
.EXAMPLE
Show-Help Get-Command
Shows the Get-Command cmdlet's online help topic
.EXAMPLE
Show-Help about_Automatic_Variables HOME
Shows the conceptual help topic about PowerShell's automatic variables online
and jumpts to the description of the $HOME variable, specifically,
.EXAMPLE
Show-Help about_Automatic_Variables HOME -CopyLink
Copies a Markdwown link to the online version of the given conceptual topic
to the clipboard, with the URL pointing to the $HOME variable's description,
specifically. -CopyUrl would copy just the URL.
#>
[CmdletBinding(DefaultParameterSetName = 'AllUsersView', HelpUri = 'https://go.microsoft.com/fwlink/?LinkID=113316')]
param(
[Parameter(Position = 0)]
[ValidateNotNullOrEmpty()]
[ArgumentCompleter( {
param($cmd, $param, $wordToComplete)
if ($wordToComplete -like 'about*' -or $wordToComplete -like '_[a-z]*') {
# As a courtesy, allow '_...' as shorthand for 'about_...'
$wordToComplete = $wordToComplete -replace '^(?:about)?_', 'about_'
# Note: This is *slow* and invoked every time what the user typed changes.
# Also, it prints a blank line below the current one, curiously.
@((Get-Help -Category HelpFile).Name) -like "$wordToComplete*"
}
else {
# Get-Help itself completes those for us, unlike the about_* topics, curiously,
# even though it does the latter in direct invocation.
# (Get-Command -Type Alias, Function, Cmdlet).Name -like "$wordToComplete*"
}
})]
[string]
${Name},
# Custom parameter:
# To support about_CommonParameters and about_Automatic_Variables with a specific
# parameter / variable name.
[Parameter(Position = 1)]
[string]
$Anchor,
[string]
${Path},
[ValidateSet('Alias', 'Cmdlet', 'Provider', 'General', 'FAQ', 'Glossary', 'HelpFile', 'ScriptCommand', 'Function', 'Filter', 'ExternalScript', 'All', 'DefaultHelp', 'Workflow', 'DscResource', 'Class', 'Configuration')]
[string[]]
${Category},
[Parameter(ParameterSetName = 'DetailedView', Mandatory = $true)]
[switch]
${Detailed},
[Parameter(ParameterSetName = 'AllUsersView')]
[switch]
${Full},
[Parameter(ParameterSetName = 'Examples', Mandatory = $true)]
[switch]
${Examples},
[Parameter(ParameterSetName = 'Parameters', Mandatory = $true)]
[string[]]
${Parameter},
[string[]]
${Component},
[string[]]
${Functionality},
[string[]]
${Role},
[Parameter(ParameterSetName = 'Online', Mandatory = $true)]
[switch]
${Local} # Custom argument - !! inversion of the Get-Help logic
,
[Parameter(ParameterSetName = 'CopyLink', Mandatory = $true)] # Custom argument - copy URL to clipboard as Markdown link.
[Alias('cp')]
[switch]
$CopyLink
,
[Parameter(ParameterSetName = 'CopyUrl', Mandatory = $true)] # Custom argument - copy URL to clipboard
[Alias('cpu')]
[switch]
$CopyUrl
)
Set-StrictMode -Version 1; $ErrorActionPreference = 'Stop'
$copyToClipboard = $CopyUrl -or $CopyLink
# The online help topic should be navigated to by default; -Local overrides in order
# to use the local help content and display it in the default text editor.
$Online = -not $Local
# Remove all wrapper-specific parameters, so that Get-Help @PSBoundParameters (if it is used),
# doesn't break.
foreach ($paramName in 'Local', 'CopyLink', 'CopyUrl') {
$null = $PSBoundParameters.Remove($paramName)
}
# Conversely, make sure the -Online switch is set appropriately.
if ($Online) { $PSBoundParameters['Online'] = $Online }
$isAboutTopic = $Name -like 'about_*'
$linkLabel = $Name
$topicsSupportedWithAnchor = 'about_CommonParameters', 'about_Automatic_Variables', 'about_Preference_Variables', 'about_Arrays'
if ($Anchor) {
if (-not (($Online -or $copyToClipboard) -and $Name -in $topicsSupportedWithAnchor)) {
throw "An -Anchor argument is only supported for online lookups and link/URL copying when combined with the following topics: $($topicsSupportedWithAnchor -join ', ')"
}
# Validate the anchor value, based on hard-coded knowledge.
# !! We do this, because there's no easy way to validate the presence of an anchor on a page, short of downloading the HTML and anlyzing it.
# !! Because of the hard-coded nature, this may have to be updated over time.
$validAnchor = switch ($Name) {
$topicsSupportedWithAnchor[0] {
# about_CommonParameters
# Determined with:
# (((get-help about_CommonParameters) -split '\r?\n' -match '^\s*-\s+\w+\s+\(.*?\)').Trim('-').Trim() -split '[ ()]' -ne '') -replace '^', "'" -replace '$', "'" -join ', '
# Note: The casing has been manually corrected to be more eye-friendly.
$namesAndAliases = 'Debug', 'db', 'ErrorAction', 'ea', 'ErrorVariable', 'ev', 'InformationAction', 'infa', 'InformationVariable', 'iv', 'OutVariable', 'ov', 'OutBuffer', 'ob', 'PipelineVariable', 'pv', 'Verbose', 'vb', 'WarningAction', 'wa', 'WarningVariable', 'wv', 'WhatIf', 'wi', 'Confirm', 'cf'
# Find the index.
$ndx = [Array]::FindIndex($namesAndAliases, [Predicate[string]] { $Anchor -eq $args[0] })
# If the index is an odd number, a short alias name was specified - the immediately preceding name contains the full name, which must be used as the anchor.
if ($ndx % 2) { --$ndx }
$Anchor = '-' + $namesAndAliases[$ndx]
$linkLabel = 'common `-{0}` parameter' -f $namesAndAliases[$ndx] # Use the proper casing
$ndx -ge 0
}
$topicsSupportedWithAnchor[1] {
# about_Automatic_Variables
# Determined with:
# (((get-help about_automatic_variables) -split '\r?\n' -match '^\s*\$\w+\s*$').Trim().Trim('$') | Sort-Object -Unique) -replace '^', "'" -replace '$', "'" -join ', '
$names = '?', '_', 'args', 'ConsoleFileName', 'Error', 'Event', 'EventArgs', 'EventSubscriber', 'ExecutionContext', 'false', 'foreach', 'HOME', 'Host', 'input', 'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'LastExitCode', 'Matches', 'MyInvocation', 'NestedPromptLevel', 'null', 'PID', 'PROFILE', 'PSBoundParameters', 'PSCmdlet', 'PSCommandPath', 'PSCulture', 'PSDebugContext', 'PSHOME', 'PSItem', 'PSScriptRoot', 'PSSenderInfo', 'PSUICulture', 'PSVersionTable', 'PWD', 'Sender', 'ShellId', 'StackTrace', 'switch', 'this', 'true'
$ndx = [Array]::FindIndex($names, [Predicate[string]] { $Anchor -eq $args[0] })
$linkLabel = 'automatic `${0}` variable' -f $names[$ndx] # Use the proper casing
$irregularAnchor = @{ '$'='section'; '?' = 'section-1'; '^' = 'section-2' }[$Anchor]
if ($irregularAnchor) { $Anchor = $irregularAnchor }
$ndx -ge 0
}
$topicsSupportedWithAnchor[2] {
# about_Preference_Variables
# Determined with:
# ((get-help about_Preference_Variables) -split '\r?\n' -match '^ \$\w+\s+').ForEach({ (-split $_)[0].TrimStart('$') }) -replace '^', "'" -replace '$', "'" -join ', '
$names = 'ConfirmPreference', 'DebugPreference', 'ErrorActionPreference', 'ErrorView', 'FormatEnumerationLimit', 'InformationPreference', 'LogCommandHealthEvent', 'LogCommandLifecycleEvent', 'LogEngineHealthEvent', 'LogEngineLifecycleEvent', 'LogProviderLifecycleEvent', 'LogProviderHealthEvent', 'MaximumHistoryCount', 'OFS', 'OutputEncoding', 'ProgressPreference', 'PSDefaultParameterValues', 'PSEmailServer', 'PSModuleAutoLoadingPreference', 'PSSessionApplicationName', 'PSSessionConfigurationName', 'PSSessionOption', 'Transcript', 'VerbosePreference', 'WarningPreference', 'WhatIfPreference', 'PSNativeCommandArgumentPassing', 'PSNativeCommandUseErrorActionPreference'
$ndx = [Array]::FindIndex($names, [Predicate[string]] { $Anchor -eq $args[0] })
$linkLabel = 'preference variable `${0}`' -f $names[$ndx] # Use the proper casing
$ndx -ge 0
}
$topicsSupportedWithAnchor[3] {
# about_Arrays
# Just the .Where() and .ForEach() method anchors
$names = 'Where', 'ForEach'
$ndx = [Array]::FindIndex($names, [Predicate[string]] { $Anchor -eq $args[0] })
$linkLabel = '`.{0}()` array method' -f $names[$ndx] # Use the proper casing
$ndx -ge 0
}
}
if (-not $validAnchor) {
throw "Invalid -Anchor argument for topic $Name."
}
# !! Anchors as URL parts are case-SENSITIVE and must be *all-lowercase*
$Anchor = $Anchor.ToLowerInvariant()
}
# Note: For online help it only makes sense to look for topics for names recognized
# as *commands*.
# Note: We needn't worry about alias resolution, Get-Help does that automatically.
if ($Online -and $Name -and -not $isAboutTopic -and -not (Get-Command -Ea Ignore $Name)) {
Throw "No command named '$Name' found."
}
if ($Online -or $copyToClipboard) {
# Open online help topic in default web browser or copy the topic URL to the clipboard.
if ($isAboutTopic) {
# Sadly, as of 7.0 about_* topics have no online URL information, but it's easy to construct them.
$url = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/$Name"
if ($Online -and -not $copyToClipboard) {
# To be consistent with how -Online normally works, append the executing engine's PowerShell version as the version-specific view.
Start-Process ($url + ('?view=powershell-' + (('{0}.{1}' -f $PSVersionTable.PSVersion.Major, $PSVersionTable.PSVersion.Minor) -replace '\.0$')) + $(if ($Anchor) { "#$Anchor" }))
return
}
}
else {
if ($Online -and -not $copyToClipboard) {
# Pass through to Get-Help -Online
Microsoft.PowerShell.Core\Get-Help @PSBoundParameters
return
}
# For -CopyLink and -CopyUrl: Derive the URL from the properties of the target help topic, but strip the version-specific view request,
# because we want to copy version-agnostic URLs to the clipboard.
[string] $url = (@((Microsoft.PowerShell.Core\Get-Help -Name $Name).relatedlinks.navigationLink.uri) -ne '')[0] # remove
if (-not $url) {
Throw "No online help-topic URL found for: $Name"
}
elseif ($url -like '*go.microsoft.com/fwlink*') {
# URL is a short version that redirects to the ultimate URL; find that URL and lop off the query-string part.
try { $url = [System.Net.HttpWebRequest]::Create($url).GetResponse().ResponseUri.AbsoluteUri -replace '\?.+$' } catch { throw }
}
# Remove the query-string part, such as '?view=powershell-6&WT.mc_id=ps-gethelp'
$url = $url -replace '\?.+$'
}
if ($Anchor) {
$url += "#$Anchor"
}
# -CopyLink or -CopyUrl
# Convert to Markdown links.
if ($CopyLink) {
$label = if ($isAboutTopic) {
$linkLabel
}
else {
$cmd = (Get-Command -Name $Name) # $url -replace '^.*/' -replace '\?.+$' -replace '-', '`'
if ($cmd.ResolvedCommand) { $cmd = $cmd.ResolvedCommand }
'`{0}`' -f $cmd.Name
}
$textToCopy = '[{0}]({1})' -f $label, $url
}
else {
# $CopyUrl
$textToCopy = $url
}
Write-Verbose "Copying URL / Markdown link to the clipboard: $textToCopy"
Set-Clipboard $textToCopy
return
}
# -Local specified:
# Change default detail level to -Full, capture the output in a temporary file,
# and display it in the default text editor.
if (-not $PSBoundParameters.ContainsKey('Full') -or -not $PSBoundParameters.ContainsKey('Detailed') -or -not $PSBoundParameters.ContainsKey('Examples')) {
$PSBoundParameters.Add('Full', $true)
}
# Use a preexisting Show-Output command to capture the output in a temp. file
# and open it in the default text editor.
# If no such command can be found, define it now.
if (-not ((Get-Command -ErrorAction Ignore Show-Output))) {
function Show-Output {
$tmpFile = (Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())) + '.txt'
$Input > $tmpFile
Invoke-Item -LiteralPath $tmpFile
# Quietly try to delete the file after a number of seconds, under the assumption
# that the text editor that has the file open won't complain.
Start-Process -NoNewWindow -FilePath (Get-Process -Id $PID).Path -Args '-c', "Start-Sleep 5; Remove-Item -ErrorAction Ignore -LiteralPath `"$tmpFile`""
}
}
Microsoft.PowerShell.Core\Get-Help @PSBoundParameters | Out-String | Show-Output
}
# --------------------------------
# GENERIC INSTALLATION HELPER CODE
# --------------------------------
# Provides guidance for making the function persistently available when
# this script is either directly invoked from the originating Gist or
# dot-sourced after download.
# IMPORTANT:
# * DO NOT USE `exit` in the code below, because it would exit
# the calling shell when Invoke-Expression is used to directly
# execute this script's content from GitHub.
# * Because the typical invocation is DOT-SOURCED (via Invoke-Expression),
# do not define variables or alter the session state via Set-StrictMode, ...
# *except in child scopes*, via & { ... }
if ($MyInvocation.Line -eq '') {
# Most likely, this code is being executed via Invoke-Expression directly
# from gist.github.com
# To simulate for testing with a local script, use the following:
# Note: Be sure to use a path and to use "/" as the separator.
# iex (Get-Content -Raw ./script.ps1)
# Derive the function name from the invocation command, via the enclosing
# script name presumed to be contained in the URL.
# NOTE: Unfortunately, when invoked via Invoke-Expression, $MyInvocation.MyCommand.ScriptBlock
# with the actual script content is NOT available, so we cannot extract
# the function name this way.
& {
param($invocationCmdLine)
# Try to extract the function name from the URL.
$funcName = $invocationCmdLine -replace '^.+/(.+?)(?:\.ps1).*$', '$1'
if ($funcName -eq $invocationCmdLine) {
# Function name could not be extracted, just provide a generic message.
# Note: Hypothetically, we could try to extract the Gist ID from the URL
# and use the REST API to determine the first filename.
Write-Verbose -Verbose "Function is now defined in this session."
}
else {
# Indicate that the function is now defined and also show how to
# add it to the $PROFILE or convert it to a script file.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
* If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
* If you want to convert this function into a script file that you can invoke
directly, run:
"`${function:$funcName}" | Set-Content $funcName.ps1 -Encoding $('utf8' + ('', 'bom')[[bool] (Get-Variable -ErrorAction Ignore IsCoreCLR -ValueOnly)])
"@
}
} $MyInvocation.MyCommand.Definition # Pass the original invocation command line to the script block.
}
else {
# Invocation presumably as a local file after manual download,
# either dot-sourced (as it should be) or mistakenly directly.
& {
param($originalInvocation)
# Parse this file to reliably extract the name of the embedded function,
# irrespective of the name of the script file.
$ast = $originalInvocation.MyCommand.ScriptBlock.Ast
$funcName = $ast.Find( { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false).Name
if ($originalInvocation.InvocationName -eq '.') {
# Being dot-sourced as a file.
# Provide a hint that the function is now loaded and provide
# guidance for how to add it to the $PROFILE.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
"@
}
else {
# Mistakenly directly invoked.
# Issue a warning that the function definition didn't take effect and
# provide guidance for reinvocation and adding to the $PROFILE.
Write-Warning @"
This script contains a definition for function "$funcName", but this definition
only takes effect if you dot-source this script.
To define this function for the current session, run:
. "$($originalInvocation.MyCommand.Path)"
"@
}
} $MyInvocation # Pass the original invocation info to the helper script block.
}
@Konfekt
Copy link

Konfekt commented Sep 13, 2021

This and the many other gists of yours are splendid very useful functions. The mode of deployment complicates keeping the PS1 files up to date though, as curl --fail --show-error --silent --location --output "$name" "$url" of the script $name sourced in the profile will always add the Write-Verbose -Verbose @" code explaining how to add the function to the profile.

@mklement0
Copy link
Author

mklement0 commented Sep 13, 2021

Glad to hear they're useful, @Konfekt.

So you're looking to download these Gists as .ps1 files you can invoke directly?

If you're dot-sourcing them so as to define the embedded function, you can suppress the verbose output with a redirection; e.g.:

. ./Show-Help.ps1 4>$null

Similarly, you can append 4>$null directly to the iex call for direct definition; e.g.:

irm https://gist.github.com/mklement0/880624fd665073bb439dfff5d71da886/raw/Show-Help.ps1 | iex 4>$null

However, if you call the above from a script, you'll get a (false) warning about dot-sourcing instead, which you can suppress with 3>$null; e.g.:

# From a script
irm https://gist.github.com/mklement0/880624fd665073bb439dfff5d71da886/raw/Show-Help.ps1 | iex 3>$null

However, please see the caveat below.

@Konfekt
Copy link

Konfekt commented Sep 14, 2021

Thank you!

@mklement0
Copy link
Author

You're welcome, @Konfekt; in future Gists I'll add a hint re 4>$null in the comment at the top.

I should add a slight caveat:

I hadn't really anticipated that users methodically pull down the latest version of my Gists every time.

While I try to maintain backward compatibility between versions, it isn't guaranteed.

While I try to make sure that a Gist is helpful and works as advertised, the choice of a Gist as the publication mechanism means that I don't want to spend the effort to create a full-fledged module with proper tests, explicit version control, semantic versioning, ...

@mklement0
Copy link
Author

mklement0 commented Sep 10, 2022

P.S., @Konfekt:

To avoid the risk of a later revision breaking existing code, you can adapt the irm ... | iex command to use a link (URL) to a specific revision of the Gist that you know to be working, which is guaranteed not to change (you should upgrade to a later revision only after testing first).

You can obtain such a link as follows:

  • Click on Revisions in the top left corner of the window.
  • Click on ... next to the revision of interest, and click on View File in the dropdown menu.
  • Click on Raw on the right side of the header above the revision's source code.
  • Copy the URL of the page that opens, which is your permalink to the targeted revision; it is composed as follows:
https://gist.githubusercontent.com/<user>/<gist-ID>/raw/<commit-id>/<filename>

Here's an example URL that locks in the latest revision as of this writing:

https://gist.githubusercontent.com/mklement0/880624fd665073bb439dfff5d71da886/raw/07605b0030258de155982b94837a943a49cb20d7/Show-Help.ps1

To spell out its use in the context of the irm ... | iex command, usable in a script:

irm https://gist.githubusercontent.com/mklement0/880624fd665073bb439dfff5d71da886/raw/07605b0030258de155982b94837a943a49cb20d7/Show-Help.ps1 | iex 3>$null

@Konfekt
Copy link

Konfekt commented Sep 12, 2022

Hello @mklement0 ,

thank you very much again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment