Functions/GenXdev.Coding.PowerShell.Modules/Get-ModuleCmdletMetaData.ps1
|
############################################################################### <# .SYNOPSIS Retrieves metadata for all cmdlets in a PowerShell module. .DESCRIPTION Retrieves full cmdlet metadata (synopsis, description, parameters, examples, outputs, and aliases) for every cmdlet in the specified module, adding SubModuleName and CmdletType properties to each result. Sub-module assignment uses two independent paths: - Script cmdlets (.ps1): source file matched against .psm1 dot-source directory mappings (Functions\<(Sub)ModuleName>\FileName.ps1). The dot-source pattern . "$PSScriptRoot\Functions\<Sub>\<File>" is parsed from each .psm1 to build the filename-to-sub-module map. A fallback scans Functions\*\ directly for modules without nested .psm1 files. - Compiled cmdlets (.dll / .cs): namespace from ImplementationType.Namespace (e.g., GenXdev.FileSystem). C# source files are named Verb-Noun.cs or Verb-Noun.Cmdlet.cs (the .Cmdlet suffix denotes a partial class co-located with helper types). They use [Cmdlet("Verb", "Noun")] for registration and [System.ComponentModel.Description(@"...")] for comment-based help using the same .KEYWORD syntax as PowerShell (.SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE, etc.). The .EXAMPLE keyword uses fenced ```powershell code blocks with the description below the fence (vs. unfenced code in .ps1 help). To see how a C# cmdlet receives its metadata through attributes only, visit: https://github.com/genXdev/GenXdev.PowerShell/blob/main/Functions/GenXdev.FileSystem/Find-Item.Cmdlet.cs .LICENSE Copyright (C) 2026 René Vaessen / GenXdev This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. .PARAMETER ModuleName The name of the PowerShell module to retrieve cmdlet metadata for (e.g., 'GenXdev', 'Microsoft.WinGet.Client'). .PARAMETER Language BCP 47 language tag for translating help text (e.g., nl-NL, de-DE). .PARAMETER Model The model identifier or pattern to use for AI translations. .PARAMETER ApiEndpoint The API endpoint URL for AI translations. .PARAMETER ApiKey The API key for authenticated AI operations. .PARAMETER PromptForSettings When specified, launches an interactive prompt to configure the LLM settings .PARAMETER TranslationInstructions Custom instructions for the AI translation model. Passed through to Get-CmdletMetaData. .PARAMETER SkipTranslation Skip LLM-based translation; keep help text in the source language even when -Language is specified. .EXAMPLE Get-ModuleCmdletMetaData -ModuleName 'GenXdev' Returns metadata for all cmdlets in the GenXdev module. .EXAMPLE Get-ModuleCmdletMetaData -ModuleName 'GenXdev' -Language 'nl-NL' Returns Dutch-translated metadata for all GenXdev cmdlets. #> function Get-ModuleCmdletMetaData { [CmdletBinding()] [OutputType([hashtable[]])] param ( ####################################################################### [Parameter( Position = 0, Mandatory = $true, HelpMessage = 'The name of the PowerShell module to retrieve ' + 'cmdlet metadata for' )] [ValidateNotNullOrEmpty()] [string] $ModuleName, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'BCP 47 language tag for translation (e.g., ' + 'nl-NL, de-DE)' )] [string] $Language, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = ('The model identifier or pattern to use for AI translations') )] [string] $Model, ########################################################################### [Parameter( Mandatory = $false, HelpMessage = 'The API endpoint URL for AI translations' )] [string] $ApiEndpoint, ########################################################################### [Parameter( Mandatory = $false, HelpMessage = 'The API key for authenticated AI translations' )] [string] $ApiKey, ################################################################### [Parameter( Mandatory = $false, HelpMessage = ('Launch interactive prompt to configure LLM settings') )] [switch] $PromptForSettings, ########################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Indicates that LLM has no support for JSON schemas' )] [switch] $NoSupportForJsonSchema, ########################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Custom AI translation instructions' )] [string] $TranslationInstructions, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Skip LLM-based translation' )] [switch] $SkipTranslation ) begin { ############################################################################### # Internal helper: build filename → sub-module-name mapping from .psm1 files. ############################################################################### function GetSubModuleMap { param( [System.Management.Automation.PSModuleInfo]$Module ) [System.Collections.Hashtable] $map = @{} $moduleRoot = $Module.ModuleBase if (-not $moduleRoot) { Microsoft.PowerShell.Utility\Write-Warning ` 'GetSubModuleMap: Module has no ModuleBase path.' return $map } $mainPsm1 = "$($Module.Name).psm1" # -- Strategy 1: Read .psd1 NestedModules key (most authoritative) ------ $psm1Files = [System.Collections.Generic.List[System.IO.FileInfo]]::new() if ($Module.Path -and (Microsoft.PowerShell.Management\Test-Path ` $Module.Path -PathType Leaf)) { try { $psd1Content = [System.IO.File]::ReadAllText($Module.Path) # Extract everything between NestedModules = @( and the matching ) $regex = [regex]::new( "NestedModules\s*=\s*@\(([\s\S]*?)\)", [System.Text.RegularExpressions.RegexOptions]::Multiline) $match = $regex.Match($psd1Content) if ($match.Success) { $nestedStr = $match.Groups[1].Value $fileRegex = [regex]::new( "'([^']*\.psm1)'", [System.Text.RegularExpressions.RegexOptions]::Multiline) foreach ($fm in $fileRegex.Matches($nestedStr)) { $relativePath = $fm.Groups[1].Value $fullPath = [System.IO.Path]::Combine( $moduleRoot, $relativePath) if ([System.IO.File]::Exists($fullPath)) { $null = $psm1Files.Add( [System.IO.FileInfo]::new($fullPath)) } } } } catch { Microsoft.PowerShell.Utility\Write-Verbose ( "GetSubModuleMap: Failed to parse .psd1: $_") } } # -- Strategy 2: Glob *.psm1 files (fallback if .psd1 didn't yield any) - if ($psm1Files.Count -eq 0) { $globbed = Microsoft.PowerShell.Management\Get-ChildItem ` "$moduleRoot\*.psm1" -File -ErrorAction SilentlyContinue | Microsoft.PowerShell.Core\Where-Object { $_.Name -ne $mainPsm1 } foreach ($g in $globbed) { $null = $psm1Files.Add($g) } } # -- Parse each .psm1 for dot-source lines ------------------------------ # Pattern: . "$PSScriptRoot\Functions\<SubModuleName>\<FileName>" $dotSourceRegex = [regex]::new( '\.\s*"\$PSScriptRoot\\Functions\\([^\\]+)\\([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Multiline) foreach ($psm1 in $psm1Files) { $content = [System.IO.File]::ReadAllText($psm1.FullName) foreach ($m in $dotSourceRegex.Matches($content)) { $subModuleName = $m.Groups[1].Value $fileName = $m.Groups[2].Value if (-not $map.ContainsKey($fileName)) { $map[$fileName] = $subModuleName } } # Also map the .psm1 filename itself (e.g., GenXdev.AI.psm1 → # GenXdev.AI). When loaded via .psm1, ScriptBlock.File points to # the .psm1 file, not the original .ps1 source. $map[$psm1.Name] = $psm1.BaseName } # -- Strategy 3: Scan Functions\*\ directly (no .psm1 sub-modules) ------ if ($map.Count -eq 0) { $functionsRoot = [System.IO.Path]::Combine($moduleRoot, 'Functions') if ([System.IO.Directory]::Exists($functionsRoot)) { $functionsDirs = [System.IO.Directory]::GetDirectories( $functionsRoot) foreach ($dir in $functionsDirs) { $subModuleName = [System.IO.Path]::GetFileName($dir) $files = [System.IO.Directory]::GetFiles($dir) foreach ($file in $files) { $fileName = [System.IO.Path]::GetFileName($file) if (-not $map.ContainsKey($fileName)) { $map[$fileName] = $subModuleName } } } } } return $map } ############################################################################### # Internal helper: resolve the sub-module name for a single cmdlet. ############################################################################### function ResolveSubModuleName { param( [System.Management.Automation.CommandInfo] $Cmd, [System.Management.Automation.PSModuleInfo] $Module, [System.Collections.Hashtable] $SubModuleMap ) $mainModuleName = if ($Module) { $Module.Name } else { '' } # -- Path A: C# compiled cmdlet → namespace ----------------------------- if ($Cmd.CommandType -eq 'Cmdlet') { try { $type = $Cmd.ImplementingType if ($type -and $type.Namespace) { Microsoft.PowerShell.Utility\Write-Verbose ( "ResolveSubModuleName: '$($Cmd.Name)' → namespace " + "'$($type.Namespace)'") return $type.Namespace } } catch { Microsoft.PowerShell.Utility\Write-Verbose ( "ResolveSubModuleName: Failed to get namespace for " + "'$($Cmd.Name)': $_") } } # -- Path B: Script cmdlet → .psm1 dot-source map ----------------------- if ($Cmd.ScriptBlock -and $Cmd.ScriptBlock.File) { $sourceFile = [System.IO.Path]::GetFileName($Cmd.ScriptBlock.File) if ($SubModuleMap -and $SubModuleMap.ContainsKey($sourceFile)) { $result = $SubModuleMap[$sourceFile] Microsoft.PowerShell.Utility\Write-Verbose ( "ResolveSubModuleName: '$($Cmd.Name)' → file '${sourceFile}' " + "→ sub-module '${result}'") return $result } # Fallback: extract sub-module from Functions\<Name>\ directory $sourcePath = $Cmd.ScriptBlock.File $parentDir = [System.IO.Path]::GetDirectoryName($sourcePath) $grandParentDir = [System.IO.Path]::GetDirectoryName($parentDir) if ($grandParentDir -and [System.IO.Path]::GetFileName($grandParentDir) -eq 'Functions') { $result = [System.IO.Path]::GetFileName($parentDir) Microsoft.PowerShell.Utility\Write-Verbose ( "ResolveSubModuleName: '$($Cmd.Name)' → Functions folder " + "→ sub-module '${result}'") return $result } } # -- Path C: Fallback → main module name -------------------------------- Microsoft.PowerShell.Utility\Write-Verbose ( "ResolveSubModuleName: '$($Cmd.Name)' → no sub-module matched, " + "using '${mainModuleName}'") return $mainModuleName } ################################################################################ # -- Resolve module ------------------------------------------------- # Prefer the actually-loaded module (has ExportedCommands). # Otherwise fall back to the available module with the highest # version number. $module = @(Microsoft.PowerShell.Core\Get-Module -Name $ModuleName ` -ErrorAction SilentlyContinue | Microsoft.PowerShell.Core\Where-Object { $_.ExportedCommands.Count -gt 0 -and $_.ModuleType -ne 'Binary' })[0] if (-not $module) { $module = @(Microsoft.PowerShell.Core\Get-Module ` -Name $ModuleName -ListAvailable ` -ErrorAction SilentlyContinue | Microsoft.PowerShell.Utility\Sort-Object Version -Descending)[0] } if (-not $module) { Microsoft.PowerShell.Utility\Write-Error ` "Module '${ModuleName}' not found." return } Microsoft.PowerShell.Utility\Write-Verbose ` "Module root: $($module.ModuleBase)" # -- Build sub-module map ------------------------------------------- $subModuleMap = GetSubModuleMap -Module $module # -- Discover all cmdlets in the module ----------------------------- $allCmdlets = @($module.ExportedCommands.Values | Microsoft.PowerShell.Core\Where-Object { $_.CommandType -in @('Cmdlet', 'Function') } | Microsoft.PowerShell.Utility\Sort-Object Name) if ($allCmdlets.Count -eq 0) { Microsoft.PowerShell.Utility\Write-Warning ` "No cmdlets found in module '${ModuleName}'." return } Microsoft.PowerShell.Utility\Write-Verbose ` "Found $($allCmdlets.Count) cmdlet(s) in '${ModuleName}'." } process { # -- Build pass-through parameters for Get-CmdletMetaData ----------- $metaBaseParams = GenXdev\Copy-IdenticalParamValues ` -BoundParameters $PSBoundParameters ` -FunctionName "GenXdev\Get-CmdletMetaData" ; # -- Collect metadata for all cmdlets ------------------------------- $result = [System.Collections.Generic.List[hashtable]]::new() $i = 0; foreach ($cmd in $allCmdlets) { Microsoft.PowerShell.Utility\Write-Progress -Id 425 -Activity "Getting module metadata" -Status "Processing $($cmd.Name)" -PercentComplete ([math]::Round((($i + 1) / $allCmdlets.Count) * 100, 2)) $i++; $resolvedName = $cmd.Name # Determine SubModuleName. $subModuleName = ResolveSubModuleName -Cmd $cmd ` -Module $module -SubModuleMap $subModuleMap # Get metadata from the base cmdlet. $metaParams = $metaBaseParams.Clone() $metaParams['Name'] = $resolvedName try { $meta = GenXdev\Get-CmdletMetaData @metaParams ` -ErrorAction Stop if ($meta) { $meta['CmdletName'] = $resolvedName $meta['SubModuleName'] = $subModuleName $meta['CmdletType'] = if ($cmd.CommandType -eq 'Cmdlet') { 'Cmdlet' } else { 'Function' } $null = $result.Add($meta) } } catch { Microsoft.PowerShell.Utility\Write-Warning ` "Failed to get metadata for '${resolvedName}': $_" } } Microsoft.PowerShell.Utility\Write-Verbose ` "Retrieved metadata for $($result.Count) cmdlet(s)." return $result.ToArray() } end { Microsoft.PowerShell.Utility\Write-Progress -Id 425 -Completed } } |