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.
}
@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