Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Pester Tests — ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
- uses: actions/checkout@v4

- name: Install Pester
shell: pwsh
run: Install-Module Pester -Force -SkipPublisherCheck -Scope CurrentUser -MinimumVersion 5.0

- name: Run tests
shell: pwsh
run: |
Import-Module Pester -MinimumVersion 5.0
$cfg = New-PesterConfiguration
$cfg.Run.Path = './tests'
$cfg.Output.Verbosity = 'Detailed'
$cfg.TestResult.Enabled = $true
$cfg.TestResult.OutputFormat = 'NUnitXml'
$cfg.TestResult.OutputPath = './test-results.xml'
Invoke-Pester -Configuration $cfg

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.os }}
path: test-results.xml
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Eric Hansen

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
10 changes: 7 additions & 3 deletions PSCommandHelper/PSCommandHelper.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'PSCommandHelper.psm1'
ModuleVersion = '0.1.0'
ModuleVersion = '0.2.0'
GUID = 'a3f8b2c1-4d5e-6f7a-8b9c-0d1e2f3a4b5c'
Author = 'Eric Hansen'
Description = 'Learn PowerShell by doing. Detects bash/Linux commands and suggests PowerShell equivalents with explanations.'
Expand All @@ -10,6 +10,8 @@
'Enable-PSCommandHelper'
'Disable-PSCommandHelper'
'Get-CommandMapping'
'Register-PSCommandHelperPrompt'
'Unregister-PSCommandHelperPrompt'
)

CmdletsToExport = @()
Expand All @@ -18,8 +20,10 @@

PrivateData = @{
PSData = @{
Tags = @('PowerShell', 'Learning', 'Bash', 'Linux', 'Helper', 'Education')
ProjectUri = ''
Tags = @('PowerShell', 'Learning', 'Bash', 'Linux', 'Helper', 'Education')
ProjectUri = 'https://github.com/ericchansen/PSCommandHelper'
LicenseUri = 'https://github.com/ericchansen/PSCommandHelper/blob/main/LICENSE'
ReleaseNotes = 'v0.2.0: Two-tier detection (CommandNotFoundAction + prompt handler for aliased commands), $PSStyle support, cross-platform install, Type metadata on all mappings.'
}
}
}
170 changes: 87 additions & 83 deletions PSCommandHelper/Private/CommandMap.ps1

Large diffs are not rendered by default.

71 changes: 57 additions & 14 deletions PSCommandHelper/Private/Format-Suggestion.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,72 @@ function Format-Suggestion {
[string]$OriginalCommand
)

$esc = [char]27

# Colors
$reset = "$esc[0m"
$bold = "$esc[1m"
$dim = "$esc[2m"
$yellow = "$esc[33m"
$green = "$esc[32m"
$cyan = "$esc[36m"
$magenta = "$esc[35m"
$bgDark = "$esc[48;5;236m"
# Use $PSStyle if available (PS 7.2+), otherwise raw ANSI
$hasPSStyle = $null -ne (Get-Variable -Name PSStyle -ErrorAction SilentlyContinue)
$isInteractive = $Host.Name -eq 'ConsoleHost'

if ($hasPSStyle -and $isInteractive) {
$reset = $PSStyle.Reset
$bold = $PSStyle.Bold
$dim = $PSStyle.Formatting.FormatAccent
$yellow = $PSStyle.Foreground.Yellow
$green = $PSStyle.Foreground.Green
$cyan = $PSStyle.Foreground.Cyan
$magenta = $PSStyle.Foreground.Magenta
}
elseif ($isInteractive) {
$esc = [char]27
$reset = "$esc[0m"
$bold = "$esc[1m"
$dim = "$esc[2m"
$yellow = "$esc[33m"
$green = "$esc[32m"
$cyan = "$esc[36m"
$magenta = "$esc[35m"
}
else {
# Non-interactive: no color
$reset = $bold = $dim = $yellow = $green = $cyan = $magenta = ''
}

$divider = "$dim$('─' * 60)$reset"

# Adapt header by type
$type = $Mapping.Type
switch ($type) {
'Aliased' {
$header = "💡 $yellow${bold}PSCommandHelper$reset ${dim}(alias tip)$reset"
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
$tryThis = " $dim PS equivalent:$reset $green${bold}$($Mapping.PowerShell)$reset"
$note = " ${dim}Note: ``$($Mapping.Bash)`` is already aliased in PS, but the flags differ.$reset"
}
'Executable' {
$header = "💡 $yellow${bold}PSCommandHelper$reset ${dim}(native alternative)$reset"
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
$tryThis = " $dim PS-native:$reset $green${bold}$($Mapping.PowerShell)$reset"
$note = " ${dim}Note: ``$($Mapping.Bash)`` exists as a Windows .exe, but the PS-native version returns rich objects.$reset"
}
default {
$header = "💡 $yellow${bold}PSCommandHelper$reset"
$youTyped = " $dim You typed:$reset $yellow$OriginalCommand$reset"
$tryThis = " $dim Try this:$reset $green${bold}$($Mapping.PowerShell)$reset"
$note = $null
}
}

Write-Host ""
Write-Host $divider
Write-Host " 💡 $yellow${bold}PSCommandHelper$reset"
Write-Host " $header"
Write-Host ""
Write-Host " $dim You typed:$reset $yellow$OriginalCommand$reset"
Write-Host " $dim Try this:$reset $green${bold}$($Mapping.PowerShell)$reset"
Write-Host $youTyped
Write-Host $tryThis
Write-Host ""
Write-Host " $cyan$($Mapping.Explanation)$reset"

if ($note) {
Write-Host $note
}

if ($Mapping.Example) {
Write-Host ""
Write-Host " ${dim}Example:$reset"
Expand Down
4 changes: 4 additions & 0 deletions PSCommandHelper/Public/Disable-PSCommandHelper.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ function Disable-PSCommandHelper {
$ExecutionContext.InvokeCommand.CommandNotFoundAction = $newHandler
}
$script:PSCommandHelperHandler = $null

# Also unregister the prompt handler
Unregister-PSCommandHelperPrompt

Write-Host "🔴 PSCommandHelper disabled." -ForegroundColor Yellow
}
else {
Expand Down
3 changes: 3 additions & 0 deletions PSCommandHelper/Public/Enable-PSCommandHelper.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ function Enable-PSCommandHelper {
$script:PSCommandHelperHandler = $typedHandler

Write-Host "✅ PSCommandHelper enabled. Type a bash command to see the PowerShell equivalent!" -ForegroundColor Green

# Also register the prompt handler for aliased commands (ls -la, rm -rf, etc.)
Register-PSCommandHelperPrompt
}
18 changes: 16 additions & 2 deletions PSCommandHelper/Public/Get-CommandMapping.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,29 @@ function Get-CommandMapping {
Useful for proactive learning — browse the table to discover PowerShell equivalents.
.PARAMETER Search
Optional search term to filter mappings. Searches both bash and PowerShell columns.
.PARAMETER Type
Optional filter by detection type: Hook, Aliased, or Executable.
.EXAMPLE
Get-CommandMapping
Lists all mappings.
.EXAMPLE
Get-CommandMapping -Search "grep"
Shows mappings related to grep.
.EXAMPLE
Get-CommandMapping -Type Hook
Shows only commands that trigger the CommandNotFoundAction hook.
.EXAMPLE
Get-CommandMapping -Search "file"
Shows mappings with "file" in any field.
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Search
[string]$Search,

[Parameter()]
[ValidateSet('Hook', 'Aliased', 'Executable')]
[string]$Type
)

$map = Get-BashToPowerShellMap
Expand All @@ -33,6 +42,10 @@ function Get-CommandMapping {
}
}

if ($Type) {
$map = $map | Where-Object { $_.Type -eq $Type }
}

if (-not $map) {
Write-Host "No mappings found for '$Search'." -ForegroundColor Yellow
return
Expand All @@ -54,7 +67,8 @@ function Get-CommandMapping {
Write-Host " $dim$('─' * 56)$reset"

foreach ($entry in $map) {
Write-Host " $yellow$($entry.Bash.PadRight(20))$reset → $green${bold}$($entry.PowerShell)$reset"
$typeTag = switch ($entry.Type) { 'Hook' { '🔵' } 'Aliased' { '🟡' } 'Executable' { '🟢' } default { '⚪' } }
Write-Host " $typeTag $yellow$($entry.Bash.PadRight(20))$reset → $green${bold}$($entry.PowerShell)$reset"
Write-Host " $dim$($entry.Explanation)$reset"
Write-Host ""
}
Expand Down
93 changes: 93 additions & 0 deletions PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
function Register-PSCommandHelperPrompt {
<#
.SYNOPSIS
Registers a prompt wrapper that catches bash-style flag errors on aliased commands.
.DESCRIPTION
Commands like ls, rm, cp are aliased in PowerShell, so CommandNotFoundAction
doesn't fire for them. But when users pass bash-style flags (ls -la, rm -rf),
PowerShell errors on the unrecognized parameters. This prompt wrapper detects
those errors and shows educational suggestions.
#>
[CmdletBinding()]
param()

# Prevent double-registration
if ($script:OriginalPrompt) {
Write-Verbose "PSCommandHelper prompt handler is already registered."
return
}

# Save the current prompt
$script:OriginalPrompt = $function:prompt

# Build the aliased-command lookup from the map
$map = Get-BashToPowerShellMap
$aliasedMap = @{}
foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) {
$baseCmd = ($entry.Bash -split '\s+')[0]
if (-not $aliasedMap.ContainsKey($baseCmd)) {
$aliasedMap[$baseCmd] = @()
}
$aliasedMap[$baseCmd] += $entry
}
$script:AliasedCommandMap = $aliasedMap
$script:FormatFunc = Get-Command Format-Suggestion

$function:global:prompt = {
# Check if the last command failed
if (-not $? -and $global:Error.Count -gt 0) {
$lastErr = $global:Error[0]
try {
# Extract the command name from the error
$cmdName = $null
if ($lastErr.InvocationInfo -and $lastErr.InvocationInfo.MyCommand) {
$cmdName = $lastErr.InvocationInfo.MyCommand.Name
}
elseif ($lastErr.CategoryInfo -and $lastErr.CategoryInfo.Activity) {
$cmdName = $lastErr.CategoryInfo.Activity
}

if ($cmdName) {
# Check if an alias resolves to a known command
$alias = Get-Alias -Name $cmdName -ErrorAction SilentlyContinue
$lookupName = if ($alias) { $cmdName } else { $null }

# Also check if the failing command itself is in our aliased map
if (-not $lookupName -and $script:AliasedCommandMap.ContainsKey($cmdName)) {
$lookupName = $cmdName
}

if ($lookupName -and $script:AliasedCommandMap.ContainsKey($lookupName)) {
$errString = $lastErr.ToString()
# Only show for parameter-binding or argument errors (bash-style flags)
$isParamError = $lastErr.Exception -is [System.Management.Automation.ParameterBindingException] -or
$errString -match 'is not recognized as' -or
$errString -match 'A positional parameter cannot be found' -or
$errString -match 'Cannot find a parameter'

if ($isParamError) {
# Find the best matching entry (most specific first)
$line = if ($lastErr.InvocationInfo) { $lastErr.InvocationInfo.Line.Trim() } else { $lookupName }
$entries = $script:AliasedCommandMap[$lookupName]
$bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending |
Where-Object { $line -match [regex]::Escape($_.Bash) } |
Select-Object -First 1

if (-not $bestMatch) {
$bestMatch = $entries | Select-Object -First 1
}

& $script:FormatFunc -Mapping $bestMatch -OriginalCommand $line
}
}
}
}
catch {
# Silently ignore errors in the prompt handler
}
}

# Call the original prompt
& $script:OriginalPrompt
}
}
14 changes: 14 additions & 0 deletions PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
function Unregister-PSCommandHelperPrompt {
<#
.SYNOPSIS
Removes the prompt wrapper installed by Register-PSCommandHelperPrompt.
#>
[CmdletBinding()]
param()

if ($script:OriginalPrompt) {
$function:global:prompt = $script:OriginalPrompt
$script:OriginalPrompt = $null
$script:AliasedCommandMap = $null
}
}
Loading