Auto-Learning Auto-Completion

Increase usability for your PowerShell functions by adding automatic parameter completion that learns from the user.

Autocompletion is awesome and makes using PowerShell so much easier. Typically, though, the autocompletion data is provided by the engine or your function. That’s why autocompletion is available predominantly for technical things such as paths, types, or prefixed lists and enumerations.

They are typically not able to adapt to the needs of users, i.e. suggest computer names in use in your infrastructure. And you as a PowerShell author can’t help this because you don’t know the suggestions that would be useful for a user of your function.

Let’s change that.

Add Auto-Learning Auto-Completion

With the magic of transformation attributes, you can effortlessly add auto-learning auto-completion to the parameters of your functions that a user trains.

Run this to see for yourself:

For the most part, this script is defining the new attributes that you can later reuse throughout your functions. So when you collapse the region at the beginning, you’ll see that there is just very little code to digest.


#region adding the new attributes
class AutoLearnAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'

    # define prefix character used to delete the hint list
    [char]$ClearKey = '!'

    # define parameterless constructor:
    AutoLearnAttribute() : base()
    {}

    # define constructor with parameter for id:
    AutoLearnAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    # Transform() is called whenever there is a variable or parameter assignment, and returns the value
    # that is actually assigned:
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.hint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # add to hashtable
              ForEach-Object {
                # value is not used, set it to $true:
                $hints[$_] = $true
              }
        }

        # does the user input start with the clearing key?
        if ($inputData.StartsWith($this.ClearKey))
        {
            # remove the prefix:
            $inputData = $inputData.SubString(1)

            # clear the hint list:
            $hints.Clear()
        }

        # add new value to hint list
        if(![string]::IsNullOrWhiteSpace($inputData))
        {
            $hints[$inputData] = $true
        }
        # save hints list
        $hints.Keys | Sort-Object | Set-Content -Path $hintPath -Encoding Default 
        
        # return the user input (if there was a clearing key at its start,
        # it is now stripped):
        return $inputData
    }
}

class AutoCompleteAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    # define path to store hint lists
    [string]$Path = "$env:temp\hints"

    # define id to manage multiple hint lists:
    [string]$Id = 'default'
  
    # define parameterless constructor:
    AutoCompleteAttribute() : base([AutoCompleteAttribute]::_createScriptBlock($this)) 
    {}

    # define constructor with parameter for id:
    AutoCompleteAttribute([string]$Id) : base([AutoCompleteAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    # create a static helper method that creates the scriptblock that the base constructor needs
    # this is necessary to be able to access the argument(s) submitted to the constructor
    # the method needs a reference to the object instance to (later) access its optional parameters:
    hidden static [ScriptBlock] _createScriptBlock([AutoCompleteAttribute] $instance)
    {
    $scriptblock = {
        # receive information about current state:
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        # create filename for hint list
        $filename = '{0}.hint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            Get-Content -Path $hintPath -Encoding Default |
              # remove leading and trailing blanks
              ForEach-Object { $_.Trim() } |
              # remove empty lines
              Where-Object { ![string]::IsNullOrEmpty($_) } |
              # filter completion items based on existing text:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              # create argument completion results
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}
#endregion

#region CacheCredential
# attribute [CacheCredential()] transforms strings to creds and caches values:
class CacheCredentialAttribute : System.Management.Automation.ArgumentTransformationAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
    [char]$ClearKey = '!'

    CacheCredentialAttribute() : base()
    {}

    CacheCredentialAttribute([string]$Id) : base()
    {
        $this.Id = $Id
    }
    
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData)
    {
        # make sure the folder with hints exists
        $exists = Test-Path -Path $this.Path
        if (!$exists) { $null = New-Item -Path $this.Path -ItemType Directory }

        # create filename for hint list
        $filename = '{0}.xmlhint' -f $this.Id
        $hintPath = Join-Path -Path $this.Path -ChildPath $filename
        
        # use a hashtable to keep hint list
        $hints = @{}

        # read hint list if it exists
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # hint list is xml data
            # it is a serialized hashtable and can be 
            # deserialized via Import-CliXml if it exists
            # result is a hashtable:
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath
        }

        # if the argument is a string...
        if ($inputData -is [string])
        {
            # does username start with "!"?
            [bool]$promptAlways = $inputData.StartsWith($this.ClearKey)

            # if not,...
            if (!$promptAlways)
            {
                # ...check to see if the username has been used before,
                # and re-use its credential (no need to enter password again)
                if ($hints.ContainsKey($inputData))
                {
                    return $hints[$inputData]
                }
            }
            else
            {
                # ...else, remove the "!" at the beginning and prompt
                # again for the password (this way, passwords can be updated)
                $inputData = $inputData.SubString(1)
                # delete the cached credentials
                $hints.Clear()
            }
            # ask for a credential:
            $cred = $engineIntrinsics.Host.UI.PromptForCredential("Enter password", "Please enter user account and password", $inputData, "")
            # add the credential to the hashtable:
            $hints[$cred.UserName] = $cred
            # update the hashtable and write it to file
            # passwords are automatically safely encrypted:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $cred
        }
        # if a credential was submitted...
        elseif ($inputData -is [PSCredential])
        {
            # save it to the hashtable:
            $hints[$inputData.UserName] = $inputData
            # update the hashtable and write it to file:
            $hints | Export-Clixml -Path $hintPath
            # return the credential:
            return $inputData
        }
        throw [System.InvalidOperationException]::new('Unexpected error.')
    }
}


# attribute [SuggestCredential()] suggests cached credentials
class SuggestCredentialAttribute : System.Management.Automation.ArgumentCompleterAttribute
{
    [string]$Path = "$env:temp\hints"
    [string]$Id = 'default'
  
    SuggestCredentialAttribute() : base([SuggestCredentialAttribute]::_createScriptBlock($this)) 
    {}

    SuggestCredentialAttribute([string]$Id) : base([SuggestCredentialAttribute]::_createScriptBlock($this))
    {
        $this.Id = $Id
    }

    hidden static [ScriptBlock] _createScriptBlock([SuggestCredentialAttribute] $instance)
    {
    $scriptblock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
   
        $filename = '{0}.xmlhint' -f $instance.Id
        $hintPath = Join-Path -Path $instance.Path -ChildPath $filename
        
        $exists = Test-Path -Path $hintPath
        if ($exists) 
        {
            # read serialized hint hashtable if it exists...
            [System.Collections.Hashtable]$hints = Import-Clixml -Path $hintPath

            # hint the sorted list of cached user names...
            $hints.Keys |
              Where-Object { $_ } |
              # ...that still match the current user input:
              Where-Object { $_.LogName -like "$wordToComplete*" } | 
              Sort-Object |
              # return completion results:
              Foreach-Object { 
                  [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
              }
        }
    }.GetNewClosure()
    return $scriptblock
    }
}


# function uses a parameter -Credential that accepts both
# strings (username) and credentials. If the user submits a
# string, a cached credential is returned if available.
# only if there is no cached credential yet, the user gets
# prompted for a password, and the string is transformed
# into a credential.
# when the user prefixes a username with "!", all cached
# credentials are deleted´.
#endregion

# see: https://powershell.one/powershell-internals/attributes/custom-attributes
function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        [AutoLearn('server')][AutoComplete('server')]
        $ComputerName,

        [PSCredential]
        [CacheCredential('account')][SuggestCredential('account')]
        $Credential
    )

    # expose password:
    if ($Credential) {
      $password = $Credential.GetNetworkCredential().Password
      $username = $Credential.UserName
    }

    "hello $Username, connecting you to $ComputerName using password $password."
}

The code important for you is just this part:

# make sure you ran the code above to define the new attributes!

function Connect-MyServer
{
    param
    (
        [string]
        [Parameter(Mandatory)]
        [AutoLearn('server')][AutoComplete('server')]
        $ComputerName,

        [PSCredential]
        [CacheCredential('account')][SuggestCredential('account')]
        $Credential
    )

    # expose password:
    if ($Credential) {
      $password = $Credential.GetNetworkCredential().Password
      $username = $Credential.UserName
    }

    "hello $Username, connecting you to $ComputerName using password $password."
}

Once you run the code, you have one function Connect-MyServer which has two parameters: -ComputerName and -Credential.

Run Connect-MyServera couple of times, and you’ll notice that autocompletion learns from you.

It will suggest computer names and credentials that you entered previously. So this is a perfect user-centric scenario where you and your functions just provide the ability for the user to teach the computer names and credentials to your function over time.

Due to a bug in all PowerShell versions, autocompletion will generally never work inside the very same editor pane where you defined the attributes. It works like a charm in any other editor window, and of course in the interactive PowerShell console.

The cache is persistent and works across restarts. Credentials are stored encrypted. To clear the cache list, prepend the argument with “!”.

How this works

There is just a minor change to the param() block to enable this for any PowerShell function or script:

param
    (
        [string]
        [Parameter(Mandatory)]
        [AutoLearn('server')][AutoComplete('server')]
        $ComputerName,

        [PSCredential]
        [CacheCredential('account')][SuggestCredential('account')]
        $Credential
    )

For string-based parameters, use the new attributes [AutoLearn()] and [AutoComplete()], and submit a keyword. The keyword basically defines the cache list, and if you want parameters to share the same cache list, use the same keyword.

For credential-based parameters, use the new attributes [CacheCredential()] and [SuggestCredential()], and again submit a keyword. Like before, you should use the same keyword for the same credential cache list.

Further Reading

This article is just a short version of this way more elaborate background article. It is basically a proof-of-concept up for grabs, and there are a lot of things you might want to add.

For example, you might want to polish attribute naming. You could hook up the credentials cache to some secret vault or credentials manager instead of saving the values to an encrypted xml file (as it is done right now).

Wish List for the PowerShell Team

I believe this approach is so useful and adds so much advantage to the usability of any PowerShell function or script that my hope is that these attributes might be added to PowerShell natively at one time.

Currently, because of how the PowerShell engine works, there is the requirement for two attributes, one dealing with autocompletion and another dealing with caching. That is something only the PowerShell engine can fix and consolidate into one simple attribute.

Another challenge currently is that PowerShell modules do not support classes, so you cannot add the attribute definitions to modules. That, too, would be overcome when PowerShell shipped these attributes out of the box. Let’s keep fingers crossed!