Skip to content

Commit e2bfe01

Browse files
ericchansenCopilot
andcommitted
feat: v0.2 — two-tier detection, System.Management.Automation.PSStyle, cross-platform install
Addresses Round Table review findings: - Add Type metadata (Hook/Aliased/Executable) to all 75+ command mappings - Add prompt handler for aliased commands (catches ls -la, rm -rf, etc.) - Use \System.Management.Automation.PSStyle on PS7.2+ with raw ANSI fallback for 7.0-7.1 - Adapt suggestion messaging per detection type - Make install.ps1 cross-platform (\C:\Users\erichansen\OneDrive - Microsoft\Documents\PowerShell\Modules;C:\Program Files\PowerShell\Modules;c:\program files\powershell\7\Modules;C:\Program Files\WindowsPowerShell\Modules;C:\Windows\system32\WindowsPowerShell\v1.0\Modules-based) - Add -Type filter to Get-CommandMapping - Add LICENSE file (MIT) - Fill ProjectUri/LicenseUri/ReleaseNotes in manifest - Expand test suite to 18 tests (was 10) - Update README with two-tier detection docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 84feb09 commit e2bfe01

12 files changed

Lines changed: 431 additions & 129 deletions

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Eric Hansen
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
@{
22
RootModule = 'PSCommandHelper.psm1'
3-
ModuleVersion = '0.1.0'
3+
ModuleVersion = '0.2.0'
44
GUID = 'a3f8b2c1-4d5e-6f7a-8b9c-0d1e2f3a4b5c'
55
Author = 'Eric Hansen'
66
Description = 'Learn PowerShell by doing. Detects bash/Linux commands and suggests PowerShell equivalents with explanations.'
@@ -10,6 +10,8 @@
1010
'Enable-PSCommandHelper'
1111
'Disable-PSCommandHelper'
1212
'Get-CommandMapping'
13+
'Register-PSCommandHelperPrompt'
14+
'Unregister-PSCommandHelperPrompt'
1315
)
1416

1517
CmdletsToExport = @()
@@ -18,8 +20,10 @@
1820

1921
PrivateData = @{
2022
PSData = @{
21-
Tags = @('PowerShell', 'Learning', 'Bash', 'Linux', 'Helper', 'Education')
22-
ProjectUri = ''
23+
Tags = @('PowerShell', 'Learning', 'Bash', 'Linux', 'Helper', 'Education')
24+
ProjectUri = 'https://github.com/ericchansen/PSCommandHelper'
25+
LicenseUri = 'https://github.com/ericchansen/PSCommandHelper/blob/main/LICENSE'
26+
ReleaseNotes = 'v0.2.0: Two-tier detection (CommandNotFoundAction + prompt handler for aliased commands), $PSStyle support, cross-platform install, Type metadata on all mappings.'
2327
}
2428
}
2529
}

PSCommandHelper/Private/CommandMap.ps1

Lines changed: 87 additions & 83 deletions
Large diffs are not rendered by default.

PSCommandHelper/Private/Format-Suggestion.ps1

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,72 @@ function Format-Suggestion {
88
[string]$OriginalCommand
99
)
1010

11-
$esc = [char]27
12-
13-
# Colors
14-
$reset = "$esc[0m"
15-
$bold = "$esc[1m"
16-
$dim = "$esc[2m"
17-
$yellow = "$esc[33m"
18-
$green = "$esc[32m"
19-
$cyan = "$esc[36m"
20-
$magenta = "$esc[35m"
21-
$bgDark = "$esc[48;5;236m"
11+
# Use $PSStyle if available (PS 7.2+), otherwise raw ANSI
12+
$hasPSStyle = $null -ne (Get-Variable -Name PSStyle -ErrorAction SilentlyContinue)
13+
$isInteractive = $Host.Name -eq 'ConsoleHost'
14+
15+
if ($hasPSStyle -and $isInteractive) {
16+
$reset = $PSStyle.Reset
17+
$bold = $PSStyle.Bold
18+
$dim = $PSStyle.Formatting.FormatAccent
19+
$yellow = $PSStyle.Foreground.Yellow
20+
$green = $PSStyle.Foreground.Green
21+
$cyan = $PSStyle.Foreground.Cyan
22+
$magenta = $PSStyle.Foreground.Magenta
23+
}
24+
elseif ($isInteractive) {
25+
$esc = [char]27
26+
$reset = "$esc[0m"
27+
$bold = "$esc[1m"
28+
$dim = "$esc[2m"
29+
$yellow = "$esc[33m"
30+
$green = "$esc[32m"
31+
$cyan = "$esc[36m"
32+
$magenta = "$esc[35m"
33+
}
34+
else {
35+
# Non-interactive: no color
36+
$reset = $bold = $dim = $yellow = $green = $cyan = $magenta = ''
37+
}
2238

2339
$divider = "$dim$('' * 60)$reset"
2440

41+
# Adapt header by type
42+
$type = $Mapping.Type
43+
switch ($type) {
44+
'Aliased' {
45+
$header = "💡 $yellow${bold}PSCommandHelper$reset ${dim}(alias tip)$reset"
46+
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
47+
$tryThis = " $dim PS equivalent:$reset $green${bold}$($Mapping.PowerShell)$reset"
48+
$note = " ${dim}Note: ``$($Mapping.Bash)`` is already aliased in PS, but the flags differ.$reset"
49+
}
50+
'Executable' {
51+
$header = "💡 $yellow${bold}PSCommandHelper$reset ${dim}(native alternative)$reset"
52+
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
53+
$tryThis = " $dim PS-native:$reset $green${bold}$($Mapping.PowerShell)$reset"
54+
$note = " ${dim}Note: ``$($Mapping.Bash)`` exists as a Windows .exe, but the PS-native version returns rich objects.$reset"
55+
}
56+
default {
57+
$header = "💡 $yellow${bold}PSCommandHelper$reset"
58+
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
59+
$tryThis = " $dim Try this:$reset $green${bold}$($Mapping.PowerShell)$reset"
60+
$note = $null
61+
}
62+
}
63+
2564
Write-Host ""
2665
Write-Host $divider
27-
Write-Host " 💡 $yellow${bold}PSCommandHelper$reset"
66+
Write-Host " $header"
2867
Write-Host ""
29-
Write-Host " $dim You typed:$reset $yellow$OriginalCommand$reset"
30-
Write-Host " $dim Try this:$reset $green${bold}$($Mapping.PowerShell)$reset"
68+
Write-Host $youTyped
69+
Write-Host $tryThis
3170
Write-Host ""
3271
Write-Host " $cyan$($Mapping.Explanation)$reset"
3372

73+
if ($note) {
74+
Write-Host $note
75+
}
76+
3477
if ($Mapping.Example) {
3578
Write-Host ""
3679
Write-Host " ${dim}Example:$reset"

PSCommandHelper/Public/Disable-PSCommandHelper.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ function Disable-PSCommandHelper {
1515
$ExecutionContext.InvokeCommand.CommandNotFoundAction = $newHandler
1616
}
1717
$script:PSCommandHelperHandler = $null
18+
19+
# Also unregister the prompt handler
20+
Unregister-PSCommandHelperPrompt
21+
1822
Write-Host "🔴 PSCommandHelper disabled." -ForegroundColor Yellow
1923
}
2024
else {

PSCommandHelper/Public/Enable-PSCommandHelper.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,7 @@ function Enable-PSCommandHelper {
6464
$script:PSCommandHelperHandler = $typedHandler
6565

6666
Write-Host "✅ PSCommandHelper enabled. Type a bash command to see the PowerShell equivalent!" -ForegroundColor Green
67+
68+
# Also register the prompt handler for aliased commands (ls -la, rm -rf, etc.)
69+
Register-PSCommandHelperPrompt
6770
}

PSCommandHelper/Public/Get-CommandMapping.ps1

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,29 @@ function Get-CommandMapping {
77
Useful for proactive learning — browse the table to discover PowerShell equivalents.
88
.PARAMETER Search
99
Optional search term to filter mappings. Searches both bash and PowerShell columns.
10+
.PARAMETER Type
11+
Optional filter by detection type: Hook, Aliased, or Executable.
1012
.EXAMPLE
1113
Get-CommandMapping
1214
Lists all mappings.
1315
.EXAMPLE
1416
Get-CommandMapping -Search "grep"
1517
Shows mappings related to grep.
18+
.EXAMPLE
19+
Get-CommandMapping -Type Hook
20+
Shows only commands that trigger the CommandNotFoundAction hook.
1621
.EXAMPLE
1722
Get-CommandMapping -Search "file"
1823
Shows mappings with "file" in any field.
1924
#>
2025
[CmdletBinding()]
2126
param(
2227
[Parameter(Position = 0)]
23-
[string]$Search
28+
[string]$Search,
29+
30+
[Parameter()]
31+
[ValidateSet('Hook', 'Aliased', 'Executable')]
32+
[string]$Type
2433
)
2534

2635
$map = Get-BashToPowerShellMap
@@ -33,6 +42,10 @@ function Get-CommandMapping {
3342
}
3443
}
3544

45+
if ($Type) {
46+
$map = $map | Where-Object { $_.Type -eq $Type }
47+
}
48+
3649
if (-not $map) {
3750
Write-Host "No mappings found for '$Search'." -ForegroundColor Yellow
3851
return
@@ -54,7 +67,8 @@ function Get-CommandMapping {
5467
Write-Host " $dim$('' * 56)$reset"
5568

5669
foreach ($entry in $map) {
57-
Write-Host " $yellow$($entry.Bash.PadRight(20))$reset$green${bold}$($entry.PowerShell)$reset"
70+
$typeTag = switch ($entry.Type) { 'Hook' { '🔵' } 'Aliased' { '🟡' } 'Executable' { '🟢' } default { '' } }
71+
Write-Host " $typeTag $yellow$($entry.Bash.PadRight(20))$reset$green${bold}$($entry.PowerShell)$reset"
5872
Write-Host " $dim$($entry.Explanation)$reset"
5973
Write-Host ""
6074
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
function Register-PSCommandHelperPrompt {
2+
<#
3+
.SYNOPSIS
4+
Registers a prompt wrapper that catches bash-style flag errors on aliased commands.
5+
.DESCRIPTION
6+
Commands like ls, rm, cp are aliased in PowerShell, so CommandNotFoundAction
7+
doesn't fire for them. But when users pass bash-style flags (ls -la, rm -rf),
8+
PowerShell errors on the unrecognized parameters. This prompt wrapper detects
9+
those errors and shows educational suggestions.
10+
#>
11+
[CmdletBinding()]
12+
param()
13+
14+
# Prevent double-registration
15+
if ($script:OriginalPrompt) {
16+
Write-Verbose "PSCommandHelper prompt handler is already registered."
17+
return
18+
}
19+
20+
# Save the current prompt
21+
$script:OriginalPrompt = $function:prompt
22+
23+
# Build the aliased-command lookup from the map
24+
$map = Get-BashToPowerShellMap
25+
$aliasedMap = @{}
26+
foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) {
27+
$baseCmd = ($entry.Bash -split '\s+')[0]
28+
if (-not $aliasedMap.ContainsKey($baseCmd)) {
29+
$aliasedMap[$baseCmd] = @()
30+
}
31+
$aliasedMap[$baseCmd] += $entry
32+
}
33+
$script:AliasedCommandMap = $aliasedMap
34+
$script:FormatFunc = Get-Command Format-Suggestion
35+
36+
$function:global:prompt = {
37+
# Check if the last command failed
38+
if (-not $? -and $global:Error.Count -gt 0) {
39+
$lastErr = $global:Error[0]
40+
try {
41+
# Extract the command name from the error
42+
$cmdName = $null
43+
if ($lastErr.InvocationInfo -and $lastErr.InvocationInfo.MyCommand) {
44+
$cmdName = $lastErr.InvocationInfo.MyCommand.Name
45+
}
46+
elseif ($lastErr.CategoryInfo -and $lastErr.CategoryInfo.Activity) {
47+
$cmdName = $lastErr.CategoryInfo.Activity
48+
}
49+
50+
if ($cmdName) {
51+
# Check if an alias resolves to a known command
52+
$alias = Get-Alias -Name $cmdName -ErrorAction SilentlyContinue
53+
$lookupName = if ($alias) { $cmdName } else { $null }
54+
55+
# Also check if the failing command itself is in our aliased map
56+
if (-not $lookupName -and $script:AliasedCommandMap.ContainsKey($cmdName)) {
57+
$lookupName = $cmdName
58+
}
59+
60+
if ($lookupName -and $script:AliasedCommandMap.ContainsKey($lookupName)) {
61+
$errString = $lastErr.ToString()
62+
# Only show for parameter-binding or argument errors (bash-style flags)
63+
$isParamError = $lastErr.Exception -is [System.Management.Automation.ParameterBindingException] -or
64+
$errString -match 'is not recognized as' -or
65+
$errString -match 'A positional parameter cannot be found' -or
66+
$errString -match 'Cannot find a parameter'
67+
68+
if ($isParamError) {
69+
# Find the best matching entry (most specific first)
70+
$line = if ($lastErr.InvocationInfo) { $lastErr.InvocationInfo.Line.Trim() } else { $lookupName }
71+
$entries = $script:AliasedCommandMap[$lookupName]
72+
$bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending |
73+
Where-Object { $line -match [regex]::Escape($_.Bash) } |
74+
Select-Object -First 1
75+
76+
if (-not $bestMatch) {
77+
$bestMatch = $entries | Select-Object -First 1
78+
}
79+
80+
& $script:FormatFunc -Mapping $bestMatch -OriginalCommand $line
81+
}
82+
}
83+
}
84+
}
85+
catch {
86+
# Silently ignore errors in the prompt handler
87+
}
88+
}
89+
90+
# Call the original prompt
91+
& $script:OriginalPrompt
92+
}
93+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
function Unregister-PSCommandHelperPrompt {
2+
<#
3+
.SYNOPSIS
4+
Removes the prompt wrapper installed by Register-PSCommandHelperPrompt.
5+
#>
6+
[CmdletBinding()]
7+
param()
8+
9+
if ($script:OriginalPrompt) {
10+
$function:global:prompt = $script:OriginalPrompt
11+
$script:OriginalPrompt = $null
12+
$script:AliasedCommandMap = $null
13+
}
14+
}

0 commit comments

Comments
 (0)