diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bebe1c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..da72bc2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PSCommandHelper/PSCommandHelper.psd1 b/PSCommandHelper/PSCommandHelper.psd1 index 038ec77..d5a9a20 100644 --- a/PSCommandHelper/PSCommandHelper.psd1 +++ b/PSCommandHelper/PSCommandHelper.psd1 @@ -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.' @@ -10,6 +10,8 @@ 'Enable-PSCommandHelper' 'Disable-PSCommandHelper' 'Get-CommandMapping' + 'Register-PSCommandHelperPrompt' + 'Unregister-PSCommandHelperPrompt' ) CmdletsToExport = @() @@ -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.' } } } diff --git a/PSCommandHelper/Private/CommandMap.ps1 b/PSCommandHelper/Private/CommandMap.ps1 index f46e288..2dfc6a3 100644 --- a/PSCommandHelper/Private/CommandMap.ps1 +++ b/PSCommandHelper/Private/CommandMap.ps1 @@ -1,100 +1,104 @@ # Bash → PowerShell command mapping table -# Each entry: @{ Bash = '...'; PowerShell = '...'; Explanation = '...'; Example = '...' } +# Each entry: @{ Bash = '...'; PowerShell = '...'; Explanation = '...'; Example = '...'; Type = 'Hook|Aliased|Executable' } +# Type indicates how the command resolves in a default PS7 session: +# Hook — command not found, triggers CommandNotFoundAction +# Aliased — command is a built-in PS alias (rm→Remove-Item, ls→Get-ChildItem, etc.) +# Executable — command resolves as a Windows .exe (curl.exe, ping.exe, etc.) function Get-BashToPowerShellMap { @( # ── File Operations ────────────────────────────────────────── - @{ Bash = 'rm -rf'; PowerShell = 'Remove-Item -Recurse -Force'; Explanation = 'Remove-Item deletes files/folders. -Recurse handles subdirectories, -Force skips confirmation.'; Example = 'Remove-Item ./build -Recurse -Force' } - @{ Bash = 'rm -r'; PowerShell = 'Remove-Item -Recurse'; Explanation = 'Remove-Item -Recurse deletes a directory and everything inside it.'; Example = 'Remove-Item ./old-folder -Recurse' } - @{ Bash = 'rm -f'; PowerShell = 'Remove-Item -Force'; Explanation = 'Remove-Item -Force deletes without prompting for confirmation.'; Example = 'Remove-Item ./file.tmp -Force' } - @{ Bash = 'rm'; PowerShell = 'Remove-Item'; Explanation = 'Remove-Item (alias: ri, del) deletes files and folders.'; Example = 'Remove-Item ./file.txt' } - @{ Bash = 'cp -r'; PowerShell = 'Copy-Item -Recurse'; Explanation = 'Copy-Item -Recurse copies a directory and all its contents.'; Example = 'Copy-Item ./src ./backup -Recurse' } - @{ Bash = 'cp'; PowerShell = 'Copy-Item'; Explanation = 'Copy-Item (alias: copy, ci) copies files or directories.'; Example = 'Copy-Item ./file.txt ./backup.txt' } - @{ Bash = 'mv'; PowerShell = 'Move-Item'; Explanation = 'Move-Item (alias: move, mi) moves or renames files and directories.'; Example = 'Move-Item ./old.txt ./new.txt' } - @{ Bash = 'mkdir -p'; PowerShell = 'New-Item -ItemType Directory -Force'; Explanation = 'New-Item -ItemType Directory creates a folder. -Force creates parent dirs if needed.'; Example = 'New-Item -ItemType Directory -Path ./a/b/c -Force' } - @{ Bash = 'mkdir'; PowerShell = 'New-Item -ItemType Directory'; Explanation = 'New-Item -ItemType Directory creates a new folder.'; Example = 'New-Item -ItemType Directory -Path ./my-folder' } - @{ Bash = 'touch'; PowerShell = 'New-Item -ItemType File'; Explanation = 'New-Item -ItemType File creates an empty file (or use Set-Content for content).'; Example = 'New-Item -ItemType File -Path ./newfile.txt' } - @{ Bash = 'cat'; PowerShell = 'Get-Content'; Explanation = 'Get-Content (alias: gc, type) reads file contents to the pipeline.'; Example = 'Get-Content ./readme.md' } - @{ Bash = 'ls -la'; PowerShell = 'Get-ChildItem -Force'; Explanation = 'Get-ChildItem -Force shows all items including hidden. PS has no -la flag — it always shows details.'; Example = 'Get-ChildItem -Force' } - @{ Bash = 'ls -l'; PowerShell = 'Get-ChildItem'; Explanation = 'Get-ChildItem (alias: gci, dir) lists directory contents with details by default.'; Example = 'Get-ChildItem ./src' } - @{ Bash = 'ls -R'; PowerShell = 'Get-ChildItem -Recurse'; Explanation = 'Get-ChildItem -Recurse lists all files/folders recursively.'; Example = 'Get-ChildItem ./src -Recurse' } - @{ Bash = 'ls'; PowerShell = 'Get-ChildItem'; Explanation = 'Get-ChildItem (alias: gci, dir, ls) lists directory contents.'; Example = 'Get-ChildItem' } - @{ Bash = 'find'; PowerShell = 'Get-ChildItem -Recurse -Filter'; Explanation = 'Get-ChildItem -Recurse with -Filter or Where-Object replaces find for file searches.'; Example = 'Get-ChildItem -Recurse -Filter "*.log"' } - @{ Bash = 'ln -s'; PowerShell = 'New-Item -ItemType SymbolicLink'; Explanation = 'New-Item -ItemType SymbolicLink creates a symbolic link. Requires -Target for destination.'; Example = 'New-Item -ItemType SymbolicLink -Path ./link -Target ./original' } - @{ Bash = 'chmod'; PowerShell = 'Set-Acl / icacls'; Explanation = 'PowerShell uses Set-Acl or icacls.exe for file permissions (Windows ACLs, not Unix modes).'; Example = 'icacls ./file.txt /grant "Users:R"' } - @{ Bash = 'chown'; PowerShell = 'Set-Acl'; Explanation = 'Set-Acl modifies file ownership/permissions via Access Control Lists.'; Example = '$acl = Get-Acl ./file.txt; Set-Acl -Path ./file.txt -AclObject $acl' } - @{ Bash = 'stat'; PowerShell = 'Get-Item | Format-List *'; Explanation = 'Get-Item returns a FileInfo object with all metadata. Pipe to Format-List * to see everything.'; Example = 'Get-Item ./file.txt | Format-List *' } - @{ Bash = 'realpath'; PowerShell = 'Resolve-Path'; Explanation = 'Resolve-Path returns the absolute path of a relative or wildcard path.'; Example = 'Resolve-Path ./some/../file.txt' } - @{ Bash = 'basename'; PowerShell = 'Split-Path -Leaf'; Explanation = 'Split-Path -Leaf extracts the filename from a path.'; Example = 'Split-Path -Leaf "C:\Users\me\file.txt"' } - @{ Bash = 'dirname'; PowerShell = 'Split-Path -Parent'; Explanation = 'Split-Path -Parent extracts the directory portion of a path.'; Example = 'Split-Path -Parent "C:\Users\me\file.txt"' } + @{ Bash = 'rm -rf'; PowerShell = 'Remove-Item -Recurse -Force'; Explanation = 'Remove-Item deletes files/folders. -Recurse handles subdirectories, -Force skips confirmation.'; Example = 'Remove-Item ./build -Recurse -Force'; Type = 'Aliased' } + @{ Bash = 'rm -r'; PowerShell = 'Remove-Item -Recurse'; Explanation = 'Remove-Item -Recurse deletes a directory and everything inside it.'; Example = 'Remove-Item ./old-folder -Recurse'; Type = 'Aliased' } + @{ Bash = 'rm -f'; PowerShell = 'Remove-Item -Force'; Explanation = 'Remove-Item -Force deletes without prompting for confirmation.'; Example = 'Remove-Item ./file.tmp -Force'; Type = 'Aliased' } + @{ Bash = 'rm'; PowerShell = 'Remove-Item'; Explanation = 'Remove-Item (alias: ri, del) deletes files and folders.'; Example = 'Remove-Item ./file.txt'; Type = 'Aliased' } + @{ Bash = 'cp -r'; PowerShell = 'Copy-Item -Recurse'; Explanation = 'Copy-Item -Recurse copies a directory and all its contents.'; Example = 'Copy-Item ./src ./backup -Recurse'; Type = 'Aliased' } + @{ Bash = 'cp'; PowerShell = 'Copy-Item'; Explanation = 'Copy-Item (alias: copy, ci) copies files or directories.'; Example = 'Copy-Item ./file.txt ./backup.txt'; Type = 'Aliased' } + @{ Bash = 'mv'; PowerShell = 'Move-Item'; Explanation = 'Move-Item (alias: move, mi) moves or renames files and directories.'; Example = 'Move-Item ./old.txt ./new.txt'; Type = 'Aliased' } + @{ Bash = 'mkdir -p'; PowerShell = 'New-Item -ItemType Directory -Force'; Explanation = 'New-Item -ItemType Directory creates a folder. -Force creates parent dirs if needed.'; Example = 'New-Item -ItemType Directory -Path ./a/b/c -Force'; Type = 'Aliased' } + @{ Bash = 'mkdir'; PowerShell = 'New-Item -ItemType Directory'; Explanation = 'New-Item -ItemType Directory creates a new folder.'; Example = 'New-Item -ItemType Directory -Path ./my-folder'; Type = 'Aliased' } + @{ Bash = 'touch'; PowerShell = 'New-Item -ItemType File'; Explanation = 'New-Item -ItemType File creates an empty file (or use Set-Content for content).'; Example = 'New-Item -ItemType File -Path ./newfile.txt'; Type = 'Hook' } + @{ Bash = 'cat'; PowerShell = 'Get-Content'; Explanation = 'Get-Content (alias: gc, type) reads file contents to the pipeline.'; Example = 'Get-Content ./readme.md'; Type = 'Aliased' } + @{ Bash = 'ls -la'; PowerShell = 'Get-ChildItem -Force'; Explanation = 'Get-ChildItem -Force shows all items including hidden. PS has no -la flag — it always shows details.'; Example = 'Get-ChildItem -Force'; Type = 'Aliased' } + @{ Bash = 'ls -l'; PowerShell = 'Get-ChildItem'; Explanation = 'Get-ChildItem (alias: gci, dir) lists directory contents with details by default.'; Example = 'Get-ChildItem ./src'; Type = 'Aliased' } + @{ Bash = 'ls -R'; PowerShell = 'Get-ChildItem -Recurse'; Explanation = 'Get-ChildItem -Recurse lists all files/folders recursively.'; Example = 'Get-ChildItem ./src -Recurse'; Type = 'Aliased' } + @{ Bash = 'ls'; PowerShell = 'Get-ChildItem'; Explanation = 'Get-ChildItem (alias: gci, dir, ls) lists directory contents.'; Example = 'Get-ChildItem'; Type = 'Aliased' } + @{ Bash = 'find'; PowerShell = 'Get-ChildItem -Recurse -Filter'; Explanation = 'Get-ChildItem -Recurse with -Filter or Where-Object replaces find for file searches.'; Example = 'Get-ChildItem -Recurse -Filter "*.log"'; Type = 'Executable' } + @{ Bash = 'ln -s'; PowerShell = 'New-Item -ItemType SymbolicLink'; Explanation = 'New-Item -ItemType SymbolicLink creates a symbolic link. Requires -Target for destination.'; Example = 'New-Item -ItemType SymbolicLink -Path ./link -Target ./original'; Type = 'Hook' } + @{ Bash = 'chmod'; PowerShell = 'Set-Acl / icacls'; Explanation = 'PowerShell uses Set-Acl or icacls.exe for file permissions (Windows ACLs, not Unix modes).'; Example = 'icacls ./file.txt /grant "Users:R"'; Type = 'Hook' } + @{ Bash = 'chown'; PowerShell = 'Set-Acl'; Explanation = 'Set-Acl modifies file ownership/permissions via Access Control Lists.'; Example = '$acl = Get-Acl ./file.txt; Set-Acl -Path ./file.txt -AclObject $acl'; Type = 'Hook' } + @{ Bash = 'stat'; PowerShell = 'Get-Item | Format-List *'; Explanation = 'Get-Item returns a FileInfo object with all metadata. Pipe to Format-List * to see everything.'; Example = 'Get-Item ./file.txt | Format-List *'; Type = 'Hook' } + @{ Bash = 'realpath'; PowerShell = 'Resolve-Path'; Explanation = 'Resolve-Path returns the absolute path of a relative or wildcard path.'; Example = 'Resolve-Path ./some/../file.txt'; Type = 'Hook' } + @{ Bash = 'basename'; PowerShell = 'Split-Path -Leaf'; Explanation = 'Split-Path -Leaf extracts the filename from a path.'; Example = 'Split-Path -Leaf "C:\Users\me\file.txt"'; Type = 'Hook' } + @{ Bash = 'dirname'; PowerShell = 'Split-Path -Parent'; Explanation = 'Split-Path -Parent extracts the directory portion of a path.'; Example = 'Split-Path -Parent "C:\Users\me\file.txt"'; Type = 'Hook' } # ── Text Processing ────────────────────────────────────────── - @{ Bash = 'grep -r'; PowerShell = 'Select-String -Recurse'; Explanation = 'Select-String searches text with regex. -Recurse searches all files in subdirectories.'; Example = 'Select-String -Path ./src -Pattern "TODO" -Recurse' } - @{ Bash = 'grep -i'; PowerShell = 'Select-String -CaseSensitive:$false'; Explanation = 'Select-String is case-insensitive by default. Use -CaseSensitive to make it strict.'; Example = 'Select-String -Path ./log.txt -Pattern "error"' } - @{ Bash = 'grep'; PowerShell = 'Select-String'; Explanation = 'Select-String (alias: sls) searches for text patterns in files or pipeline input.'; Example = 'Select-String -Path ./app.log -Pattern "error"' } - @{ Bash = 'sed'; PowerShell = '-replace operator or ForEach-Object'; Explanation = 'Use -replace for regex substitution, or (Get-Content | ForEach-Object) for line-by-line transforms.'; Example = '(Get-Content ./file.txt) -replace "old", "new" | Set-Content ./file.txt' } - @{ Bash = 'awk'; PowerShell = 'ForEach-Object with -split'; Explanation = 'PowerShell splits fields with -split and processes with ForEach-Object or Select-Object.'; Example = 'Get-Content ./data.txt | ForEach-Object { ($_ -split "\s+")[1] }' } - @{ Bash = 'head'; PowerShell = 'Select-Object -First'; Explanation = 'Select-Object -First N returns the first N items from the pipeline.'; Example = 'Get-Content ./log.txt | Select-Object -First 10' } - @{ Bash = 'tail'; PowerShell = 'Select-Object -Last'; Explanation = 'Select-Object -Last N returns the last N items. Use Get-Content -Tail for efficient file tailing.'; Example = 'Get-Content ./log.txt -Tail 20' } - @{ Bash = 'tail -f'; PowerShell = 'Get-Content -Wait -Tail'; Explanation = 'Get-Content -Wait streams new lines as they are appended (like tail -f).'; Example = 'Get-Content ./app.log -Wait -Tail 10' } - @{ Bash = 'wc -l'; PowerShell = 'Measure-Object -Line'; Explanation = 'Measure-Object -Line counts lines. Also supports -Word and -Character.'; Example = 'Get-Content ./file.txt | Measure-Object -Line' } - @{ Bash = 'wc'; PowerShell = 'Measure-Object'; Explanation = 'Measure-Object counts lines (-Line), words (-Word), characters (-Character), or computes stats.'; Example = 'Get-Content ./file.txt | Measure-Object -Line -Word -Character' } - @{ Bash = 'sort'; PowerShell = 'Sort-Object'; Explanation = 'Sort-Object sorts pipeline input by property. Use -Unique to deduplicate.'; Example = 'Get-Content ./names.txt | Sort-Object' } - @{ Bash = 'uniq'; PowerShell = 'Sort-Object -Unique'; Explanation = 'Sort-Object -Unique removes duplicates (bash uniq requires sorted input; PS does too with Get-Unique).'; Example = 'Get-Content ./list.txt | Sort-Object -Unique' } - @{ Bash = 'cut'; PowerShell = 'ForEach-Object with -split or .Substring()'; Explanation = 'Use -split to break lines into fields, then index the field you want.'; Example = 'Get-Content ./csv.txt | ForEach-Object { ($_ -split ",")[0] }' } - @{ Bash = 'tr'; PowerShell = '-replace or .Replace()'; Explanation = 'Use the -replace operator for character translation or string .Replace() method.'; Example = '"hello world" -replace " ", "_"' } - @{ Bash = 'tee'; PowerShell = 'Tee-Object'; Explanation = 'Tee-Object sends output to a file AND passes it down the pipeline.'; Example = 'Get-Process | Tee-Object -FilePath ./procs.txt' } - @{ Bash = 'diff'; PowerShell = 'Compare-Object'; Explanation = 'Compare-Object compares two sets of objects and shows differences.'; Example = 'Compare-Object (Get-Content ./a.txt) (Get-Content ./b.txt)' } + @{ Bash = 'grep -r'; PowerShell = 'Select-String -Recurse'; Explanation = 'Select-String searches text with regex. -Recurse searches all files in subdirectories.'; Example = 'Select-String -Path ./src -Pattern "TODO" -Recurse'; Type = 'Hook' } + @{ Bash = 'grep -i'; PowerShell = 'Select-String -CaseSensitive:$false'; Explanation = 'Select-String is case-insensitive by default. Use -CaseSensitive to make it strict.'; Example = 'Select-String -Path ./log.txt -Pattern "error"'; Type = 'Hook' } + @{ Bash = 'grep'; PowerShell = 'Select-String'; Explanation = 'Select-String (alias: sls) searches for text patterns in files or pipeline input.'; Example = 'Select-String -Path ./app.log -Pattern "error"'; Type = 'Hook' } + @{ Bash = 'sed'; PowerShell = '-replace operator or ForEach-Object'; Explanation = 'Use -replace for regex substitution, or (Get-Content | ForEach-Object) for line-by-line transforms.'; Example = '(Get-Content ./file.txt) -replace "old", "new" | Set-Content ./file.txt'; Type = 'Hook' } + @{ Bash = 'awk'; PowerShell = 'ForEach-Object with -split'; Explanation = 'PowerShell splits fields with -split and processes with ForEach-Object or Select-Object.'; Example = 'Get-Content ./data.txt | ForEach-Object { ($_ -split "\s+")[1] }'; Type = 'Hook' } + @{ Bash = 'head'; PowerShell = 'Select-Object -First'; Explanation = 'Select-Object -First N returns the first N items from the pipeline.'; Example = 'Get-Content ./log.txt | Select-Object -First 10'; Type = 'Hook' } + @{ Bash = 'tail'; PowerShell = 'Select-Object -Last'; Explanation = 'Select-Object -Last N returns the last N items. Use Get-Content -Tail for efficient file tailing.'; Example = 'Get-Content ./log.txt -Tail 20'; Type = 'Hook' } + @{ Bash = 'tail -f'; PowerShell = 'Get-Content -Wait -Tail'; Explanation = 'Get-Content -Wait streams new lines as they are appended (like tail -f).'; Example = 'Get-Content ./app.log -Wait -Tail 10'; Type = 'Hook' } + @{ Bash = 'wc -l'; PowerShell = 'Measure-Object -Line'; Explanation = 'Measure-Object -Line counts lines. Also supports -Word and -Character.'; Example = 'Get-Content ./file.txt | Measure-Object -Line'; Type = 'Hook' } + @{ Bash = 'wc'; PowerShell = 'Measure-Object'; Explanation = 'Measure-Object counts lines (-Line), words (-Word), characters (-Character), or computes stats.'; Example = 'Get-Content ./file.txt | Measure-Object -Line -Word -Character'; Type = 'Hook' } + @{ Bash = 'sort'; PowerShell = 'Sort-Object'; Explanation = 'Sort-Object sorts pipeline input by property. Use -Unique to deduplicate.'; Example = 'Get-Content ./names.txt | Sort-Object'; Type = 'Aliased' } + @{ Bash = 'uniq'; PowerShell = 'Sort-Object -Unique'; Explanation = 'Sort-Object -Unique removes duplicates (bash uniq requires sorted input; PS does too with Get-Unique).'; Example = 'Get-Content ./list.txt | Sort-Object -Unique'; Type = 'Hook' } + @{ Bash = 'cut'; PowerShell = 'ForEach-Object with -split or .Substring()'; Explanation = 'Use -split to break lines into fields, then index the field you want.'; Example = 'Get-Content ./csv.txt | ForEach-Object { ($_ -split ",")[0] }'; Type = 'Hook' } + @{ Bash = 'tr'; PowerShell = '-replace or .Replace()'; Explanation = 'Use the -replace operator for character translation or string .Replace() method.'; Example = '"hello world" -replace " ", "_"'; Type = 'Hook' } + @{ Bash = 'tee'; PowerShell = 'Tee-Object'; Explanation = 'Tee-Object sends output to a file AND passes it down the pipeline.'; Example = 'Get-Process | Tee-Object -FilePath ./procs.txt'; Type = 'Aliased' } + @{ Bash = 'diff'; PowerShell = 'Compare-Object'; Explanation = 'Compare-Object compares two sets of objects and shows differences.'; Example = 'Compare-Object (Get-Content ./a.txt) (Get-Content ./b.txt)'; Type = 'Aliased' } # ── System / Process ───────────────────────────────────────── - @{ Bash = 'ps aux'; PowerShell = 'Get-Process'; Explanation = 'Get-Process (alias: gps, ps) lists all running processes with details.'; Example = 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 10' } - @{ Bash = 'ps'; PowerShell = 'Get-Process'; Explanation = 'Get-Process lists running processes. Use -Name or -Id to filter.'; Example = 'Get-Process -Name "code"' } - @{ Bash = 'kill'; PowerShell = 'Stop-Process'; Explanation = 'Stop-Process (alias: kill) terminates a process by -Id or -Name.'; Example = 'Stop-Process -Id 1234' } - @{ Bash = 'kill -9'; PowerShell = 'Stop-Process -Force'; Explanation = 'Stop-Process -Force forcefully terminates a process (like SIGKILL).'; Example = 'Stop-Process -Id 1234 -Force' } - @{ Bash = 'top'; PowerShell = 'Get-Process | Sort-Object CPU -Descending'; Explanation = 'No direct top equivalent, but sorting Get-Process by CPU approximates it.'; Example = 'while ($true) { Clear-Host; Get-Process | Sort-Object CPU -Descending | Select-Object -First 15; Start-Sleep 2 }' } - @{ Bash = 'df'; PowerShell = 'Get-PSDrive or Get-Volume'; Explanation = 'Get-PSDrive shows drive usage. Get-Volume provides detailed disk info.'; Example = 'Get-Volume | Format-Table DriveLetter, SizeRemaining, Size' } - @{ Bash = 'du'; PowerShell = 'Get-ChildItem -Recurse | Measure-Object -Property Length -Sum'; Explanation = 'Measure directory size by summing file lengths recursively.'; Example = 'Get-ChildItem ./src -Recurse | Measure-Object -Property Length -Sum' } - @{ Bash = 'env'; PowerShell = 'Get-ChildItem Env:'; Explanation = 'The Env: drive contains all environment variables as key-value pairs.'; Example = 'Get-ChildItem Env: | Sort-Object Name' } - @{ Bash = 'export'; PowerShell = '$env:VAR = "value"'; Explanation = 'Set environment variables with $env:NAME syntax. Persists for the session.'; Example = '$env:NODE_ENV = "production"' } - @{ Bash = 'which'; PowerShell = 'Get-Command'; Explanation = 'Get-Command (alias: gcm) finds where a command is defined — cmdlet, alias, function, or exe.'; Example = 'Get-Command git' } - @{ Bash = 'whoami'; PowerShell = '[Environment]::UserName or whoami.exe'; Explanation = 'whoami.exe works on Windows. Alternatively, [Environment]::UserName is pure PowerShell.'; Example = '[Environment]::UserName' } - @{ Bash = 'hostname'; PowerShell = '[Environment]::MachineName or hostname.exe'; Explanation = 'hostname.exe works, or use the .NET [Environment]::MachineName property.'; Example = '[Environment]::MachineName' } - @{ Bash = 'uname'; PowerShell = '$PSVersionTable.OS'; Explanation = '$PSVersionTable contains OS and PowerShell version info.'; Example = '$PSVersionTable.OS' } - @{ Bash = 'uptime'; PowerShell = '(Get-CimInstance Win32_OperatingSystem).LastBootUpTime'; Explanation = 'Calculate uptime from the last boot time via CIM/WMI.'; Example = '(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime' } - @{ Bash = 'xargs'; PowerShell = 'ForEach-Object'; Explanation = 'PowerShell pipelines pass objects directly — ForEach-Object processes each one.'; Example = 'Get-ChildItem *.log | ForEach-Object { Remove-Item $_ }' } + @{ Bash = 'ps aux'; PowerShell = 'Get-Process'; Explanation = 'Get-Process (alias: gps, ps) lists all running processes with details.'; Example = 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 10'; Type = 'Aliased' } + @{ Bash = 'ps'; PowerShell = 'Get-Process'; Explanation = 'Get-Process lists running processes. Use -Name or -Id to filter.'; Example = 'Get-Process -Name "code"'; Type = 'Aliased' } + @{ Bash = 'kill'; PowerShell = 'Stop-Process'; Explanation = 'Stop-Process (alias: kill) terminates a process by -Id or -Name.'; Example = 'Stop-Process -Id 1234'; Type = 'Aliased' } + @{ Bash = 'kill -9'; PowerShell = 'Stop-Process -Force'; Explanation = 'Stop-Process -Force forcefully terminates a process (like SIGKILL).'; Example = 'Stop-Process -Id 1234 -Force'; Type = 'Aliased' } + @{ Bash = 'top'; PowerShell = 'Get-Process | Sort-Object CPU -Descending'; Explanation = 'No direct top equivalent, but sorting Get-Process by CPU approximates it.'; Example = 'while ($true) { Clear-Host; Get-Process | Sort-Object CPU -Descending | Select-Object -First 15; Start-Sleep 2 }'; Type = 'Hook' } + @{ Bash = 'df'; PowerShell = 'Get-PSDrive or Get-Volume'; Explanation = 'Get-PSDrive shows drive usage. Get-Volume provides detailed disk info.'; Example = 'Get-Volume | Format-Table DriveLetter, SizeRemaining, Size'; Type = 'Hook' } + @{ Bash = 'du'; PowerShell = 'Get-ChildItem -Recurse | Measure-Object -Property Length -Sum'; Explanation = 'Measure directory size by summing file lengths recursively.'; Example = 'Get-ChildItem ./src -Recurse | Measure-Object -Property Length -Sum'; Type = 'Hook' } + @{ Bash = 'env'; PowerShell = 'Get-ChildItem Env:'; Explanation = 'The Env: drive contains all environment variables as key-value pairs.'; Example = 'Get-ChildItem Env: | Sort-Object Name'; Type = 'Hook' } + @{ Bash = 'export'; PowerShell = '$env:VAR = "value"'; Explanation = 'Set environment variables with $env:NAME syntax. Persists for the session.'; Example = '$env:NODE_ENV = "production"'; Type = 'Hook' } + @{ Bash = 'which'; PowerShell = 'Get-Command'; Explanation = 'Get-Command (alias: gcm) finds where a command is defined — cmdlet, alias, function, or exe.'; Example = 'Get-Command git'; Type = 'Hook' } + @{ Bash = 'whoami'; PowerShell = '[Environment]::UserName or whoami.exe'; Explanation = 'whoami.exe works on Windows. Alternatively, [Environment]::UserName is pure PowerShell.'; Example = '[Environment]::UserName'; Type = 'Executable' } + @{ Bash = 'hostname'; PowerShell = '[Environment]::MachineName or hostname.exe'; Explanation = 'hostname.exe works, or use the .NET [Environment]::MachineName property.'; Example = '[Environment]::MachineName'; Type = 'Executable' } + @{ Bash = 'uname'; PowerShell = '$PSVersionTable.OS'; Explanation = '$PSVersionTable contains OS and PowerShell version info.'; Example = '$PSVersionTable.OS'; Type = 'Hook' } + @{ Bash = 'uptime'; PowerShell = '(Get-CimInstance Win32_OperatingSystem).LastBootUpTime'; Explanation = 'Calculate uptime from the last boot time via CIM/WMI.'; Example = '(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime'; Type = 'Hook' } + @{ Bash = 'xargs'; PowerShell = 'ForEach-Object'; Explanation = 'PowerShell pipelines pass objects directly — ForEach-Object processes each one.'; Example = 'Get-ChildItem *.log | ForEach-Object { Remove-Item $_ }'; Type = 'Hook' } # ── Networking ─────────────────────────────────────────────── - @{ Bash = 'curl'; PowerShell = 'Invoke-RestMethod or Invoke-WebRequest'; Explanation = 'Invoke-RestMethod auto-parses JSON. Invoke-WebRequest returns full response with headers.'; Example = 'Invoke-RestMethod https://api.github.com/zen' } - @{ Bash = 'wget'; PowerShell = 'Invoke-WebRequest -OutFile'; Explanation = 'Invoke-WebRequest -OutFile downloads a file from a URL.'; Example = 'Invoke-WebRequest -Uri "https://example.com/file.zip" -OutFile ./file.zip' } - @{ Bash = 'ping'; PowerShell = 'Test-Connection'; Explanation = 'Test-Connection sends ICMP echo requests (like ping) with PowerShell object output.'; Example = 'Test-Connection google.com -Count 4' } - @{ Bash = 'ifconfig'; PowerShell = 'Get-NetIPAddress'; Explanation = 'Get-NetIPAddress shows IP configuration. Get-NetAdapter shows network adapters.'; Example = 'Get-NetIPAddress | Where-Object AddressFamily -eq "IPv4"' } - @{ Bash = 'netstat'; PowerShell = 'Get-NetTCPConnection'; Explanation = 'Get-NetTCPConnection shows active TCP connections with state and owning process.'; Example = 'Get-NetTCPConnection -State Listen' } - @{ Bash = 'ss'; PowerShell = 'Get-NetTCPConnection'; Explanation = 'Get-NetTCPConnection is the PowerShell equivalent of both netstat and ss.'; Example = 'Get-NetTCPConnection | Where-Object State -eq "Established"' } - @{ Bash = 'nslookup'; PowerShell = 'Resolve-DnsName'; Explanation = 'Resolve-DnsName performs DNS queries with rich object output.'; Example = 'Resolve-DnsName google.com' } - @{ Bash = 'scp'; PowerShell = 'Copy-Item -ToSession / -FromSession'; Explanation = 'Copy-Item with PS Remoting sessions copies files over WinRM. Or use scp.exe if OpenSSH is installed.'; Example = 'Copy-Item ./file.txt -ToSession $s -Destination "C:\remote\path"' } - @{ Bash = 'ssh'; PowerShell = 'Enter-PSSession or ssh.exe'; Explanation = 'Enter-PSSession opens an interactive remote PowerShell session. Or use the OpenSSH ssh.exe client.'; Example = 'Enter-PSSession -HostName server01 -UserName admin' } + @{ Bash = 'curl'; PowerShell = 'Invoke-RestMethod or Invoke-WebRequest'; Explanation = 'Invoke-RestMethod auto-parses JSON. Invoke-WebRequest returns full response with headers.'; Example = 'Invoke-RestMethod https://api.github.com/zen'; Type = 'Executable' } + @{ Bash = 'wget'; PowerShell = 'Invoke-WebRequest -OutFile'; Explanation = 'Invoke-WebRequest -OutFile downloads a file from a URL.'; Example = 'Invoke-WebRequest -Uri "https://example.com/file.zip" -OutFile ./file.zip'; Type = 'Hook' } + @{ Bash = 'ping'; PowerShell = 'Test-Connection'; Explanation = 'Test-Connection sends ICMP echo requests (like ping) with PowerShell object output.'; Example = 'Test-Connection google.com -Count 4'; Type = 'Executable' } + @{ Bash = 'ifconfig'; PowerShell = 'Get-NetIPAddress'; Explanation = 'Get-NetIPAddress shows IP configuration. Get-NetAdapter shows network adapters.'; Example = 'Get-NetIPAddress | Where-Object AddressFamily -eq "IPv4"'; Type = 'Hook' } + @{ Bash = 'netstat'; PowerShell = 'Get-NetTCPConnection'; Explanation = 'Get-NetTCPConnection shows active TCP connections with state and owning process.'; Example = 'Get-NetTCPConnection -State Listen'; Type = 'Executable' } + @{ Bash = 'ss'; PowerShell = 'Get-NetTCPConnection'; Explanation = 'Get-NetTCPConnection is the PowerShell equivalent of both netstat and ss.'; Example = 'Get-NetTCPConnection | Where-Object State -eq "Established"'; Type = 'Hook' } + @{ Bash = 'nslookup'; PowerShell = 'Resolve-DnsName'; Explanation = 'Resolve-DnsName performs DNS queries with rich object output.'; Example = 'Resolve-DnsName google.com'; Type = 'Executable' } + @{ Bash = 'scp'; PowerShell = 'Copy-Item -ToSession / -FromSession'; Explanation = 'Copy-Item with PS Remoting sessions copies files over WinRM. Or use scp.exe if OpenSSH is installed.'; Example = 'Copy-Item ./file.txt -ToSession $s -Destination "C:\remote\path"'; Type = 'Executable' } + @{ Bash = 'ssh'; PowerShell = 'Enter-PSSession or ssh.exe'; Explanation = 'Enter-PSSession opens an interactive remote PowerShell session. Or use the OpenSSH ssh.exe client.'; Example = 'Enter-PSSession -HostName server01 -UserName admin'; Type = 'Executable' } # ── Misc / Shell ───────────────────────────────────────────── - @{ Bash = 'echo'; PowerShell = 'Write-Output'; Explanation = 'Write-Output (alias: echo) sends objects to the pipeline. Write-Host writes directly to console.'; Example = 'Write-Output "Hello, PowerShell!"' } - @{ Bash = 'printf'; PowerShell = 'Write-Host -f or [string]::Format()'; Explanation = 'Use -f format operator or [string]::Format() for formatted strings.'; Example = '"Name: {0}, Age: {1}" -f "Alice", 30' } - @{ Bash = 'clear'; PowerShell = 'Clear-Host'; Explanation = 'Clear-Host (alias: cls, clear) clears the terminal screen.'; Example = 'Clear-Host' } - @{ Bash = 'history'; PowerShell = 'Get-History'; Explanation = 'Get-History (alias: h, history) shows command history for the current session.'; Example = 'Get-History | Select-Object -Last 20' } - @{ Bash = 'alias'; PowerShell = 'Get-Alias / Set-Alias'; Explanation = 'Get-Alias lists aliases. Set-Alias creates new ones. New-Alias prevents overwriting.'; Example = 'Set-Alias -Name ll -Value Get-ChildItem' } - @{ Bash = 'man'; PowerShell = 'Get-Help'; Explanation = 'Get-Help (alias: help) shows documentation for cmdlets. Use -Full or -Examples for more.'; Example = 'Get-Help Get-Process -Examples' } - @{ Bash = 'sudo'; PowerShell = 'Start-Process -Verb RunAs or sudo (PS 7.5+)'; Explanation = 'Start-Process -Verb RunAs elevates to admin. PowerShell 7.5+ supports the sudo command natively on Windows.'; Example = 'Start-Process pwsh -Verb RunAs' } - @{ Bash = 'exit'; PowerShell = 'exit'; Explanation = 'exit works the same — closes the current PowerShell session.'; Example = 'exit' } - @{ Bash = 'source'; PowerShell = '. (dot-source)'; Explanation = 'Dot-sourcing (.) runs a script in the current scope, importing its functions and variables.'; Example = '. ./my-script.ps1' } - @{ Bash = 'sleep'; PowerShell = 'Start-Sleep'; Explanation = 'Start-Sleep pauses execution for a specified number of seconds.'; Example = 'Start-Sleep -Seconds 5' } - @{ Bash = 'date'; PowerShell = 'Get-Date'; Explanation = 'Get-Date returns the current date/time as a DateTime object with rich formatting.'; Example = 'Get-Date -Format "yyyy-MM-dd HH:mm:ss"' } - @{ Bash = 'cal'; PowerShell = 'No built-in; use Get-Date and culture info'; Explanation = 'PowerShell has no cal command. You can script a calendar with Get-Date and loops.'; Example = 'Get-Date -Format "MMMM yyyy"' } - @{ Bash = 'tar'; PowerShell = 'Compress-Archive / Expand-Archive'; Explanation = 'Compress-Archive creates .zip files. Expand-Archive extracts them. For .tar.gz, use tar.exe.'; Example = 'Compress-Archive -Path ./folder -DestinationPath ./archive.zip' } - @{ Bash = 'zip'; PowerShell = 'Compress-Archive'; Explanation = 'Compress-Archive creates zip archives from files or directories.'; Example = 'Compress-Archive -Path ./docs -DestinationPath ./docs.zip' } - @{ Bash = 'unzip'; PowerShell = 'Expand-Archive'; Explanation = 'Expand-Archive extracts zip files to a destination directory.'; Example = 'Expand-Archive -Path ./archive.zip -DestinationPath ./output' } + @{ Bash = 'echo'; PowerShell = 'Write-Output'; Explanation = 'Write-Output (alias: echo) sends objects to the pipeline. Write-Host writes directly to console.'; Example = 'Write-Output "Hello, PowerShell!"'; Type = 'Aliased' } + @{ Bash = 'printf'; PowerShell = 'Write-Host -f or [string]::Format()'; Explanation = 'Use -f format operator or [string]::Format() for formatted strings.'; Example = '"Name: {0}, Age: {1}" -f "Alice", 30'; Type = 'Hook' } + @{ Bash = 'clear'; PowerShell = 'Clear-Host'; Explanation = 'Clear-Host (alias: cls, clear) clears the terminal screen.'; Example = 'Clear-Host'; Type = 'Aliased' } + @{ Bash = 'history'; PowerShell = 'Get-History'; Explanation = 'Get-History (alias: h, history) shows command history for the current session.'; Example = 'Get-History | Select-Object -Last 20'; Type = 'Aliased' } + @{ Bash = 'alias'; PowerShell = 'Get-Alias / Set-Alias'; Explanation = 'Get-Alias lists aliases. Set-Alias creates new ones. New-Alias prevents overwriting.'; Example = 'Set-Alias -Name ll -Value Get-ChildItem'; Type = 'Hook' } + @{ Bash = 'man'; PowerShell = 'Get-Help'; Explanation = 'Get-Help (alias: help) shows documentation for cmdlets. Use -Full or -Examples for more.'; Example = 'Get-Help Get-Process -Examples'; Type = 'Aliased' } + @{ Bash = 'sudo'; PowerShell = 'Start-Process -Verb RunAs or sudo (PS 7.5+)'; Explanation = 'Start-Process -Verb RunAs elevates to admin. PowerShell 7.5+ supports the sudo command natively on Windows.'; Example = 'Start-Process pwsh -Verb RunAs'; Type = 'Executable' } + @{ Bash = 'exit'; PowerShell = 'exit'; Explanation = 'exit works the same — closes the current PowerShell session.'; Example = 'exit'; Type = 'Hook' } + @{ Bash = 'source'; PowerShell = '. (dot-source)'; Explanation = 'Dot-sourcing (.) runs a script in the current scope, importing its functions and variables.'; Example = '. ./my-script.ps1'; Type = 'Hook' } + @{ Bash = 'sleep'; PowerShell = 'Start-Sleep'; Explanation = 'Start-Sleep pauses execution for a specified number of seconds.'; Example = 'Start-Sleep -Seconds 5'; Type = 'Aliased' } + @{ Bash = 'date'; PowerShell = 'Get-Date'; Explanation = 'Get-Date returns the current date/time as a DateTime object with rich formatting.'; Example = 'Get-Date -Format "yyyy-MM-dd HH:mm:ss"'; Type = 'Hook' } + @{ Bash = 'cal'; PowerShell = 'No built-in; use Get-Date and culture info'; Explanation = 'PowerShell has no cal command. You can script a calendar with Get-Date and loops.'; Example = 'Get-Date -Format "MMMM yyyy"'; Type = 'Hook' } + @{ Bash = 'tar'; PowerShell = 'Compress-Archive / Expand-Archive'; Explanation = 'Compress-Archive creates .zip files. Expand-Archive extracts them. For .tar.gz, use tar.exe.'; Example = 'Compress-Archive -Path ./folder -DestinationPath ./archive.zip'; Type = 'Executable' } + @{ Bash = 'zip'; PowerShell = 'Compress-Archive'; Explanation = 'Compress-Archive creates zip archives from files or directories.'; Example = 'Compress-Archive -Path ./docs -DestinationPath ./docs.zip'; Type = 'Hook' } + @{ Bash = 'unzip'; PowerShell = 'Expand-Archive'; Explanation = 'Expand-Archive extracts zip files to a destination directory.'; Example = 'Expand-Archive -Path ./archive.zip -DestinationPath ./output'; Type = 'Hook' } # ── Piping / Redirection ───────────────────────────────────── - @{ Bash = '> file'; PowerShell = 'Out-File or > (redirect)'; Explanation = 'PowerShell supports > for redirection. Out-File gives more control over encoding.'; Example = 'Get-Process > ./procs.txt' } - @{ Bash = '>> file'; PowerShell = 'Out-File -Append or >>'; Explanation = '>> appends output. Out-File -Append does the same with encoding options.'; Example = '"new line" >> ./log.txt' } - @{ Bash = '2>&1'; PowerShell = '*>&1 or 2>&1'; Explanation = 'PowerShell supports stream redirection. *>&1 merges all streams into output.'; Example = 'command 2>&1 | Out-File ./all-output.txt' } - @{ Bash = '/dev/null'; PowerShell = '$null or Out-Null'; Explanation = 'Assign to $null or pipe to Out-Null to discard output.'; Example = 'Get-Process | Out-Null' } + @{ Bash = '> file'; PowerShell = 'Out-File or > (redirect)'; Explanation = 'PowerShell supports > for redirection. Out-File gives more control over encoding.'; Example = 'Get-Process > ./procs.txt'; Type = 'Hook' } + @{ Bash = '>> file'; PowerShell = 'Out-File -Append or >>'; Explanation = '>> appends output. Out-File -Append does the same with encoding options.'; Example = '"new line" >> ./log.txt'; Type = 'Hook' } + @{ Bash = '2>&1'; PowerShell = '*>&1 or 2>&1'; Explanation = 'PowerShell supports stream redirection. *>&1 merges all streams into output.'; Example = 'command 2>&1 | Out-File ./all-output.txt'; Type = 'Hook' } + @{ Bash = '/dev/null'; PowerShell = '$null or Out-Null'; Explanation = 'Assign to $null or pipe to Out-Null to discard output.'; Example = 'Get-Process | Out-Null'; Type = 'Hook' } ) } diff --git a/PSCommandHelper/Private/Format-Suggestion.ps1 b/PSCommandHelper/Private/Format-Suggestion.ps1 index dc55350..617a0fb 100644 --- a/PSCommandHelper/Private/Format-Suggestion.ps1 +++ b/PSCommandHelper/Private/Format-Suggestion.ps1 @@ -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" diff --git a/PSCommandHelper/Public/Disable-PSCommandHelper.ps1 b/PSCommandHelper/Public/Disable-PSCommandHelper.ps1 index 4c539a3..4af367a 100644 --- a/PSCommandHelper/Public/Disable-PSCommandHelper.ps1 +++ b/PSCommandHelper/Public/Disable-PSCommandHelper.ps1 @@ -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 { diff --git a/PSCommandHelper/Public/Enable-PSCommandHelper.ps1 b/PSCommandHelper/Public/Enable-PSCommandHelper.ps1 index 3da2516..05e61b4 100644 --- a/PSCommandHelper/Public/Enable-PSCommandHelper.ps1 +++ b/PSCommandHelper/Public/Enable-PSCommandHelper.ps1 @@ -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 } diff --git a/PSCommandHelper/Public/Get-CommandMapping.ps1 b/PSCommandHelper/Public/Get-CommandMapping.ps1 index 76cf2b7..4181d46 100644 --- a/PSCommandHelper/Public/Get-CommandMapping.ps1 +++ b/PSCommandHelper/Public/Get-CommandMapping.ps1 @@ -7,12 +7,17 @@ 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. @@ -20,7 +25,11 @@ function Get-CommandMapping { [CmdletBinding()] param( [Parameter(Position = 0)] - [string]$Search + [string]$Search, + + [Parameter()] + [ValidateSet('Hook', 'Aliased', 'Executable')] + [string]$Type ) $map = Get-BashToPowerShellMap @@ -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 @@ -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 "" } diff --git a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 new file mode 100644 index 0000000..fb1c1e9 --- /dev/null +++ b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 @@ -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 + } +} diff --git a/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 new file mode 100644 index 0000000..071c515 --- /dev/null +++ b/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 @@ -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 + } +} diff --git a/README.md b/README.md index 00ba713..a69f30d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ # PSCommandHelper -> Learn PowerShell by doing. When you type a bash command that doesn't exist in PowerShell, PSCommandHelper suggests the PowerShell equivalent — with an explanation. +> Learn PowerShell by doing. When you type a bash command that doesn't work in PowerShell, PSCommandHelper suggests the PowerShell equivalent — with an explanation. ## What it does -When you type a bash/Linux command in PowerShell 7 that doesn't resolve (like `rm -rf`, `grep`, `curl`, etc.), PSCommandHelper intercepts the error and shows you: +PSCommandHelper uses **two-tier detection** to catch bash commands in PowerShell 7: + +1. **CommandNotFoundAction hook** — catches commands that don't exist at all (`grep`, `awk`, `sed`, `chmod`, `touch`, etc.) +2. **Prompt handler** — catches aliased commands used with bash-style flags (`ls -la`, `rm -rf`, `ps aux`, etc.) ``` ──────────────────────────────────────────────────────────── 💡 PSCommandHelper - You typed: rm -rf - Try this: Remove-Item -Recurse -Force + You typed: grep + Try this: Select-String - Remove-Item deletes files/folders. -Recurse handles subdirectories, -Force skips confirmation. + Select-String (alias: sls) searches for text patterns in files or pipeline input. Example: - > Remove-Item ./build -Recurse -Force + > Select-String -Path ./app.log -Pattern "error" ──────────────────────────────────────────────────────────── ``` @@ -27,12 +30,12 @@ It **does not** run the command for you — the goal is to help you learn, not t ### Quick install ```powershell -git clone powershell-helper -cd powershell-helper +git clone https://github.com/ericchansen/PSCommandHelper.git +cd PSCommandHelper .\install.ps1 ``` -This copies the module to your `Documents\PowerShell\Modules` folder and adds it to your `$PROFILE`. +This copies the module to your user Modules folder and adds it to your `$PROFILE`. Works on Windows, Linux, and macOS. ### Manual install @@ -61,6 +64,14 @@ Get-CommandMapping -Search "grep" Get-CommandMapping -Search "file" ``` +### Filter by detection type + +```powershell +Get-CommandMapping -Type Hook # Commands that trigger the hook (grep, sed, awk...) +Get-CommandMapping -Type Aliased # Commands aliased in PS (ls, rm, cp, cat...) +Get-CommandMapping -Type Executable # Commands that exist as .exe (curl, ping, ssh...) +``` + ### Temporarily disable ```powershell @@ -73,6 +84,16 @@ Disable-PSCommandHelper Enable-PSCommandHelper ``` +## How detection works + +Each mapping is tagged with a `Type` that determines how it's detected: + +| Type | Icon | Detection Method | Examples | +|------|------|-----------------|----------| +| **Hook** | 🔵 | `CommandNotFoundAction` — command doesn't exist in PS | `grep`, `awk`, `sed`, `chmod`, `touch`, `head`, `tail` | +| **Aliased** | 🟡 | Prompt handler — command exists as a PS alias but bash-style flags fail | `ls -la`, `rm -rf`, `cp -r`, `ps aux`, `kill -9` | +| **Executable** | 🟢 | Informational — command resolves as a Windows .exe | `curl`, `ping`, `ssh`, `tar`, `netstat` | + ## Covered commands The built-in mapping table covers **75+ bash commands** across these categories: @@ -89,6 +110,7 @@ The built-in mapping table covers **75+ bash commands** across these categories: ## Requirements - **PowerShell 7.0+** (uses `CommandNotFoundAction` which is not available in Windows PowerShell 5.1) +- **PS 7.2+** recommended for `$PSStyle` color support (falls back to raw ANSI on 7.0-7.1) ## Running tests @@ -96,15 +118,6 @@ The built-in mapping table covers **75+ bash commands** across these categories: Invoke-Pester ./tests/PSCommandHelper.Tests.ps1 ``` -## How it works - -PowerShell 7 exposes the `$ExecutionContext.InvokeCommand.CommandNotFoundAction` event. When the shell can't find a command by name, this event fires **before** the error is shown. PSCommandHelper registers a handler that: - -1. Receives the unrecognized command name -2. Looks it up in a hashtable of bash → PowerShell mappings -3. Displays a colorful, educational suggestion -4. Lets the original error propagate (so you know the command didn't run) - ## License MIT diff --git a/install.ps1 b/install.ps1 index 092b1c5..50f9087 100644 --- a/install.ps1 +++ b/install.ps1 @@ -15,8 +15,20 @@ param( $ErrorActionPreference = 'Stop' -# Determine target module path -$modulesDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules\PSCommandHelper' +# Determine target module path (cross-platform) +$userModulePaths = $env:PSModulePath -split [IO.Path]::PathSeparator +# Pick the first user-scoped path (typically user's home-based modules directory) +$targetBase = $userModulePaths | Where-Object { + $_ -like "*$([Environment]::GetFolderPath('UserProfile'))*" -or + $_ -like "$HOME*" +} | Select-Object -First 1 + +if (-not $targetBase) { + # Fallback: first path in PSModulePath + $targetBase = $userModulePaths[0] +} + +$modulesDir = Join-Path $targetBase 'PSCommandHelper' $sourceDir = Join-Path $PSScriptRoot 'PSCommandHelper' if (-not (Test-Path $sourceDir)) { diff --git a/tests/PSCommandHelper.Tests.ps1 b/tests/PSCommandHelper.Tests.ps1 index a568980..0157a36 100644 --- a/tests/PSCommandHelper.Tests.ps1 +++ b/tests/PSCommandHelper.Tests.ps1 @@ -16,13 +16,14 @@ Describe 'CommandMap' { } } - It 'each entry has required keys' { + It 'each entry has required keys including Type' { InModuleScope PSCommandHelper { $map = Get-BashToPowerShellMap foreach ($entry in $map) { $entry.Bash | Should -Not -BeNullOrEmpty $entry.PowerShell | Should -Not -BeNullOrEmpty $entry.Explanation | Should -Not -BeNullOrEmpty + $entry.Type | Should -BeIn @('Hook', 'Aliased', 'Executable') } } } @@ -37,12 +38,34 @@ Describe 'CommandMap' { $bashCmds | Should -Contain 'cat' } } + + It 'tags aliased commands correctly' { + InModuleScope PSCommandHelper { + $map = Get-BashToPowerShellMap + $aliased = $map | Where-Object { $_.Type -eq 'Aliased' } + $aliasedBash = $aliased | ForEach-Object { ($_.Bash -split '\s+')[0] } | Sort-Object -Unique + # These should all be Aliased + @('rm', 'ls', 'cp', 'mv', 'cat') | ForEach-Object { + $_ | Should -BeIn $aliasedBash + } + } + } + + It 'tags hook commands correctly' { + InModuleScope PSCommandHelper { + $map = Get-BashToPowerShellMap + $hooks = $map | Where-Object { $_.Type -eq 'Hook' } + $hookBash = $hooks | ForEach-Object { ($_.Bash -split '\s+')[0] } | Sort-Object -Unique + @('grep', 'sed', 'awk', 'chmod', 'touch') | ForEach-Object { + $_ | Should -BeIn $hookBash + } + } + } } Describe 'Get-CommandMapping' { It 'returns all mappings when no search term is given' { $result = Get-CommandMapping 6>&1 - # Should produce output (Write-Host captured via 6>&1) $result | Should -Not -BeNullOrEmpty } @@ -52,6 +75,13 @@ Describe 'Get-CommandMapping' { $resultText | Should -Match 'grep' } + It 'filters by Type' { + $result = Get-CommandMapping -Type 'Hook' 6>&1 + $resultText = $result -join "`n" + # Hook commands like grep should be present, aliased ones like ls should not + $resultText | Should -Match 'grep' + } + It 'handles no results gracefully' { $result = Get-CommandMapping -Search 'zzz_no_such_command_zzz' 6>&1 $resultText = $result -join "`n" @@ -60,19 +90,66 @@ Describe 'Get-CommandMapping' { } Describe 'Format-Suggestion' { - It 'produces output without error' { + It 'produces output without error for Hook type' { + InModuleScope PSCommandHelper { + $mapping = @{ + Bash = 'grep' + PowerShell = 'Select-String' + Explanation = 'Test explanation' + Example = 'Select-String -Path ./app.log -Pattern "error"' + Type = 'Hook' + } + { Format-Suggestion -Mapping $mapping -OriginalCommand 'grep' 6>&1 } | Should -Not -Throw + } + } + + It 'produces output without error for Aliased type' { + InModuleScope PSCommandHelper { + $mapping = @{ + Bash = 'ls -la' + PowerShell = 'Get-ChildItem -Force' + Explanation = 'Test explanation' + Example = 'Get-ChildItem -Force' + Type = 'Aliased' + } + { Format-Suggestion -Mapping $mapping -OriginalCommand 'ls -la' 6>&1 } | Should -Not -Throw + } + } + + It 'produces output without error for Executable type' { InModuleScope PSCommandHelper { $mapping = @{ - Bash = 'rm -rf' - PowerShell = 'Remove-Item -Recurse -Force' + Bash = 'curl' + PowerShell = 'Invoke-RestMethod' Explanation = 'Test explanation' - Example = 'Remove-Item ./test -Recurse -Force' + Example = 'Invoke-RestMethod https://example.com' + Type = 'Executable' } - { Format-Suggestion -Mapping $mapping -OriginalCommand 'rm -rf' 6>&1 } | Should -Not -Throw + { Format-Suggestion -Mapping $mapping -OriginalCommand 'curl' 6>&1 } | Should -Not -Throw } } } +Describe 'Register/Unregister-PSCommandHelperPrompt' { + AfterEach { + Unregister-PSCommandHelperPrompt 6>&1 | Out-Null + } + + It 'registers without error' { + { Register-PSCommandHelperPrompt 6>&1 } | Should -Not -Throw + } + + It 'unregisters without error' { + Register-PSCommandHelperPrompt 6>&1 | Out-Null + { Unregister-PSCommandHelperPrompt 6>&1 } | Should -Not -Throw + } + + It 'is idempotent — double register does not error' { + Register-PSCommandHelperPrompt 6>&1 | Out-Null + { Register-PSCommandHelperPrompt 6>&1 } | Should -Not -Throw + } +} + Describe 'Enable/Disable-PSCommandHelper' { AfterEach { Disable-PSCommandHelper 6>&1 | Out-Null