PowerShell で自分で定義したコマンド・エイリアスの一覧を取得する

[シェルスクリプト＆PowerShell Advent Calendar 2024](https://qiita.com/advent-calendar/2024/shell)の記事です。  
はじめて外部のアドベントカレンダーに参加させていただきました！  
至らないところ等あるかと思いますがよろしくお願いします！   

# はじめに

この記事は PowerShell で自分で定義した(Powershell にもとからあるもの以外) コマンドやエイリアスの一覧を取得するためのものです。

皆さん自分好みにシェルをカスタマイズしているかと思いますが、過去に自分が登録したものって忘れてしまいますよね..? でもいちいち `$PROFILE` を開くのも面倒だし...  
そんな方にぴったりだと思います。

本記事では2通りのやり方で実現します。

- `$PROFILE` の実行前と実行後を比較する方法
  - 面倒ですが確実です
- `FunctionInfo.ScriptBlock.File` と `AliasInfo.HelpUri` を用いる方法
  - 少し確率的な方法になってしまいます


# 基本の方針

基本は
- Alias を取得するには `Get-Alias` を、  
- 自作コマンドを取得するには `Get-ChildItem Function:` を用います。

後者に関しては以下の記事がとても参考になるのでそちらをどうぞ。

https://win.just4fun.biz/?PowerShell/Function%E3%81%AE%E4%B8%80%E8%A6%A7%E3%81%8A%E3%82%88%E3%81%B3%E5%86%85%E5%AE%B9%E3%82%92%E8%A1%A8%E7%A4%BA%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95

簡単に言えば Powershell ではコマンドも変数もドライブ(`C:`とかと同じヤツ)に入るのでそこを探すのです。ドライブについては以下の公式ドキュメントをどうぞ。

https://learn.microsoft.com/ja-jp/powershell/scripting/samples/managing-windows-powershell-drives?view=powershell-7.4

## 作成するコマンドのインタフェース

以下のようなユーザインタフェースにします。

1.	引数なしの場合: コマンドとエイリアスを一覧表示
```powershell
# 引数を与えない場合: コマンドとエイリアスを一覧表示

> Get-UserDefined

function:

Name                             SourceFile                                                                  Definition
----                             ----------                                                                  ----------
Add-PoshGitToProfile             D:\Scoop\modules\posh-git\Utils.ps1                                         ...
ConvertTo-MsysPath               D:\PowershellPrograms\PowershellCmdlets\ConvertTo-msysPath.ps1              ...
ConvertTo-Narrow                 D:\PowershellPrograms\PowershellCmdlets\ConvertCharWidth.ps1                ...
ConvertTo-Slash                  C:\Users\XXX\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 ...
ConvertTo-Wide                   D:\PowershellPrograms\PowershellCmdlets\ConvertCharWidth.ps1                ...
Copy-Date                        C:\Users\XXX\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 ...
cpwd                             C:\Users\XXX\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 ...

alias:

Name          Definition
----          ----------
code          code-insiders
cpdt          Copy-Date
ctmsys        ConvertTo-MsysPath
Rgui          C:\Program Files\R\R-4.3.0\bin\x64\Rgui.exe
```

1.	引数ありの場合: 情報を詳細表示
```powershell
# コマンド
> Get-UserDefined copy-date

function:
Name:
        Copy-Date

SourceFile:
        C:\Users\XXX\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1

Definition:

        param(
                [validateset("today", "yesterday", "tomorrow")]$when = "today",
                [validateset(
                        "yyyyMMdd",
                        "yyyy/MM/dd",
                        "yyyy-MM-dd",
                        "normal",
...

# alias
> Get-UserDefined rgui


alias:
Name:
        Rgui

Definition:
        C:\Program Files\R\R-4.3.0\bin\x64\Rgui.exe
```


# `$PROFILE` の実行前と実行後を比較する方法

この方法は `$PROFILE` を実行する前と後でオブジェクトを採取し、それを比較する方法です。


まずは以下のスクリプトを `$PROFILE` (およびそれに準ずるもの) の **先頭に** 置きます

```powershell
# default aliases and functions
$defaultAliases = (Get-Alias)
$defaultFuncs = (Get-ChildItem Function:)
Write-Output "`nIf you see all user-defined functions or aliases, type:`n`tGet-UserDefined`n"
```


次に、以下のコマンドを先ほどのファイルの **最後に** 置きます。

```powershell
function Get-UserDefined {
    param (
        [string]$name
    )
    $allAliases = (Get-Alias)
    $allFuncs = (Get-ChildItem Function:)

    # 差分を取得する
    $aliasDiff = (Compare-Object $defaultAliases $allAliases).InputObject
    $funcDiff = (Compare-Object $defaultFuncs $allFuncs).InputObject

    # 表示しやすいように PSCustomObject にする
    $userAliases = $aliasDiff | ForEach-Object {
        [PSCustomObject]@{
            Name       = $_.Name
            Definition = $_.Definition
        }
    }
    $userFuncs = $funcDiff | ForEach-Object {
        [PSCustomObject]@{
            Name       = $_.Name
            SourceFile = $_.ScriptBlock.File
            Definition = $_.Definition
        }
    }

    # もし引数が与えられていた場合の処理
    if ($name) {
        $aliasDiff | Where-Object {
            $_.Name -eq $name 
        } | ForEach-Object {
            Write-Output "`n`nalias:"
            Write-Output "Name:"
            Write-Output "`t$($_.Name)"
            Write-Output "`nDefinition:"
            Write-Output "`t$($_.Definition)"
        }
        $funcDiff | Where-Object {
            $_.Name -eq $name 
        } | ForEach-Object {
            Write-Output "`n`nfunction:"
            Write-Output "Name:"
            Write-Output "`t$($_.Name)"
            Write-Output "`nSourceFile:"
            Write-Output "`t$($_.ScriptBlock.File)"
            Write-Output "`nDefinition:"
            Write-Output $_.Definition
        }
    }
    # 引数なし
    else {
        Write-Output "`nfunction:"
        Write-Output $userFuncs | Format-Table -Property Name, SourceFile, Definition
        
        Write-Output "`nalias:"
        Write-Output $userAliases | Format-Table -Property Name, Definition
    }
}
```

これで上記2つの間で定義された alias やコマンドを参照できるというわけです。

しかしこれでは場所に依存してしまいますし、あまり便利とは言えません。まあ間にあるものは確実にキャプチャできるので確実と言えば確実ですが。


# `FunctionInfo.ScriptBlock.File` と `AliasInfo.HelpUri` を用いる方法

そこで次に確実ではないものの、不便でもない方法を紹介します。

先ほどと同じように  `Get-ChildItem Function:` と `Get-Alias` で情報を取得するのですが、それを以下のようにして選別します。

- (コマンド) `FunctionInfo.ScriptBlock.File` を使って、コマンドが定義されたファイルを調べる
  - これは確実と言えば確実です。本記事では元からあるものとモジュール以外のすべてをユーザ定義コマンドとして扱いました。これは人によって変えてもいいと思います。
- (alias) `AliasInfo.HelpUri` を使ってコマンドのヘルプの参照先として指定されている URL で選別する
  - 問題はこっちで、この `HelpUri` はヘルプドキュメントの `.LINK` で指定できるものです。これはユーザが好きに定義できます。今回ではマイクロソフト公式のURLを指定するものを除外するという方法を取りましたが、確実ではありません。
  - ヘルプドキュメントについては: [https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help)


前者に関してはもっといい方法が無いかと調べているときに、以下の先駆者様を発見し、参考にさせていただきました。

https://qiita.com/Fushihara/items/76eddc041c16f48637a3


コマンド:
```powershell

function Get-UserDefined2 {
    param (
        [string]$name
    )

    # ユーザalias
    $userAliases = (Get-Alias) | Where-Object { 
        $isUserAlias = $true
        if ($_.HelpUri) {
            # 元からある alias は Help Link が以下の2つ(多分)
            # ただしユーザであっても Help ドキュメントに以下のURLを記載することは可能なため万能ではない 
            if ($_.HelpUri -like "https://go.microsoft.com/fwlink*" -or $_.HelpUri -like "https://learn.microsoft.com/powershell/*"){
                $isUserAlias = $false   
            }
        } 
        Write-Output $isUserAlias
    } | ForEach-Object {
        [PSCustomObject]@{
            Name       = $_.Name
            Definition = $_.Definition
        }
    }

    # ユーザコマンド
    $userFuncs = (Get-ChildItem Function:) | Where-Object {
        $isUserFunc = $true
        $filepath = $_.ScriptBlock.File
        if ($filepath) {
            # ScriptBlock.File の値が空でないもの
            foreach ($path in $env:PSModulePath -split ";") {
                # module だったらユーザコマンドではない(と考える)
                if ($filepath -like "${path}*") { $isUserFunc = $false }
            }
        }
        else {
            # ScriptBlock.File の値が空だったら元からあるもの
            $isUserFunc = $false
        }
        Write-Output $isUserFunc

    } | ForEach-Object {
        [PSCustomObject]@{
            Name       = $_.Name
            SourceFile = $_.ScriptBlock.File
            Definition = $_.Definition
        }
    }

    # もし引数が与えられていた場合の処理
    if ($name) {
        $userAliases | Where-Object {
            $_.Name -eq $name 
        } | ForEach-Object {
            Write-Output "`n`nalias:"
            Write-Output "Name:"
            Write-Output "`t$($_.Name)"
            Write-Output "`nDefinition:"
            Write-Output "`t$($_.Definition)"
        }
        $userFuncs | Where-Object {
            $_.Name -eq $name 
        } | ForEach-Object {
            Write-Output "`n`nfunction:"
            Write-Output "Name:"
            Write-Output "`t$($_.Name)"
            Write-Output "`nSourceFile:"
            Write-Output "`t$($_.SourceFile)"
            Write-Output "`nDefinition:"
            Write-Output $_.Definition
        }
    }
    # 引数なし
    else {
        Write-Output "`nfunction:"
        Write-Output $userFuncs | Format-Table -Property Name, SourceFile, Definition
            
        Write-Output "`nalias:"
        Write-Output $userAliases | Format-Table -Property Name, Definition
    }
}

```

# 終わりに

結構需要が高そうなことで、先駆者様などいろいろな人が工夫していることがわかりました。

もっといい方法ないかなぁ...