Last active
May 5, 2026 16:18
-
-
Save HeyItsGilbert/095f1f7dccbd59d8be677baa9c81a0e9 to your computer and use it in GitHub Desktop.
PowerShell optimizations
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Module ClaudeHooks | |
| $hook = Read-ClaudeHookInput -Quiet | |
| if (-not $hook) { exit 0 } | |
| $filePath = $hook.tool_input.file_path | |
| if (-not $filePath) { exit 0 } | |
| $updatedInput = @{} | |
| $hook.tool_input.PSObject.Properties | ForEach-Object { $updatedInput[$_.Name] = $_.Value } | |
| $updatedInput.file_path = $filePath -replace '\\', '/' | |
| Write-ClaudeHookUpdatedInput -UpdatedInput $updatedInput |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Modules Pester | |
| Describe 'normalize-file-paths hook' { | |
| BeforeAll { | |
| $script:HookPath = Join-Path $PSScriptRoot 'normalize-file-paths.ps1' | |
| function Invoke-Hook { | |
| param([string]$InputJson) | |
| $output = $InputJson | pwsh -NoProfile -NonInteractive -File $script:HookPath 2>$null | |
| if ([string]::IsNullOrWhiteSpace($output)) { return $null } | |
| $output | ConvertFrom-Json | |
| } | |
| } | |
| Context 'path normalization' { | |
| It 'converts backslashes to forward slashes' { | |
| $result = Invoke-Hook '{"tool_name":"Write","tool_input":{"file_path":"C:\\Users\\foo\\data\\test.json","content":"hi"}}' | |
| $result.hookSpecificOutput.updatedInput.file_path | Should -Be 'C:/Users/foo/data/test.json' | |
| } | |
| It 'leaves forward slashes unchanged' { | |
| $result = Invoke-Hook '{"tool_name":"Write","tool_input":{"file_path":"C:/Users/foo/data/test.json","content":"hi"}}' | |
| $result.hookSpecificOutput.updatedInput.file_path | Should -Be 'C:/Users/foo/data/test.json' | |
| } | |
| It 'handles mixed slashes' { | |
| $result = Invoke-Hook '{"tool_name":"Edit","tool_input":{"file_path":"C:/Users\\foo/data\\test.json","old_string":"a","new_string":"b"}}' | |
| $result.hookSpecificOutput.updatedInput.file_path | Should -Be 'C:/Users/foo/data/test.json' | |
| } | |
| } | |
| Context 'field passthrough' { | |
| It 'preserves content for Write' { | |
| $result = Invoke-Hook '{"tool_name":"Write","tool_input":{"file_path":"C:\\foo\\bar.txt","content":"hello world"}}' | |
| $result.hookSpecificOutput.updatedInput.content | Should -Be 'hello world' | |
| } | |
| It 'preserves old_string and new_string for Edit' { | |
| $result = Invoke-Hook '{"tool_name":"Edit","tool_input":{"file_path":"C:\\foo\\bar.txt","old_string":"foo","new_string":"bar"}}' | |
| $result.hookSpecificOutput.updatedInput.old_string | Should -Be 'foo' | |
| $result.hookSpecificOutput.updatedInput.new_string | Should -Be 'bar' | |
| } | |
| } | |
| Context 'permission decision' { | |
| It 'returns allow' { | |
| $result = Invoke-Hook '{"tool_name":"Write","tool_input":{"file_path":"C:\\foo\\bar.txt","content":"hi"}}' | |
| $result.hookSpecificOutput.permissionDecision | Should -Be 'allow' | |
| } | |
| } | |
| Context 'no-op cases' { | |
| It 'produces no output when file_path is absent' { | |
| $result = Invoke-Hook '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | |
| $result | Should -BeNullOrEmpty | |
| } | |
| It 'produces no output on empty input' { | |
| $result = Invoke-Hook '' | |
| $result | Should -BeNullOrEmpty | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Modules ClaudeHooks | |
| function Update-BashTally { | |
| param([string]$Command, [string]$TallyPath) | |
| try { | |
| $tally = @{} | |
| if (Test-Path $TallyPath) { | |
| $loaded = Get-Content $TallyPath -Raw | ConvertFrom-Json | |
| $loaded.PSObject.Properties | ForEach-Object { $tally[$_.Name] = [int]$_.Value } | |
| } | |
| $tally[$Command] = ($tally[$Command] ?? 0) + 1 | |
| $tally | ConvertTo-Json | Set-Content $TallyPath -Encoding UTF8 | |
| $tally[$Command] | |
| } catch { } | |
| } | |
| $map = @{ | |
| 'grep' = @{ | |
| 'alternative' = 'Select-String (or use the Grep tool)' | |
| 'block' = $true | |
| } | |
| 'find' = @{ | |
| 'alternative' = 'Get-ChildItem -Recurse (or use the Glob tool)' | |
| 'block' = $true | |
| } | |
| 'cat' = @{ | |
| 'alternative' = 'Get-Content (or use the Read tool)' | |
| } | |
| 'head' = @{ | |
| 'alternative' = 'Get-Content | Select-Object -First N' | |
| } | |
| 'tail' = @{ | |
| 'alternative' = 'Get-Content | Select-Object -Last N' | |
| } | |
| 'ls' = @{ | |
| 'alternative' = 'Get-ChildItem' | |
| } | |
| 'dir' = @{ | |
| 'alternative' = 'Get-ChildItem' | |
| } | |
| 'mkdir' = @{ | |
| 'alternative' = 'New-Item -ItemType Directory' | |
| } | |
| 'rm' = @{ | |
| 'alternative' = 'Remove-Item' | |
| } | |
| 'mv' = @{ | |
| 'alternative' = 'Move-Item' | |
| } | |
| 'cp' = @{ | |
| 'alternative' = 'Copy-Item' | |
| } | |
| 'echo' = @{ | |
| 'alternative' = 'Write-Output' | |
| } | |
| 'printf' = @{ | |
| 'alternative' = 'Write-Host / -f format operator' | |
| } | |
| 'sed' = @{ | |
| 'alternative' = '-replace operator or -split / -join' | |
| } | |
| 'awk' = @{ | |
| 'alternative' = 'ForEach-Object pipeline' | |
| } | |
| 'wc' = @{ | |
| 'alternative' = 'Measure-Object' | |
| } | |
| 'sort' = @{ | |
| 'alternative' = 'Sort-Object' | |
| } | |
| 'uniq' = @{ | |
| 'alternative' = 'Get-Unique' | |
| } | |
| 'pwd' = @{ | |
| 'alternative' = 'Get-Location' | |
| } | |
| 'curl' = @{ | |
| 'alternative' = 'Invoke-RestMethod / Invoke-WebRequest' | |
| } | |
| 'wget' = @{ | |
| 'alternative' = 'Invoke-WebRequest' | |
| } | |
| 'which' = @{ | |
| 'alternative' = 'Get-Command' | |
| } | |
| 'env' = @{ | |
| 'alternative' = 'Get-ChildItem Env:' | |
| } | |
| 'kill' = @{ | |
| 'alternative' = 'Stop-Process' | |
| } | |
| 'date' = @{ | |
| 'alternative' = 'Get-Date' | |
| } | |
| 'sleep' = @{ | |
| 'alternative' = 'Start-Sleep' | |
| } | |
| 'touch' = @{ | |
| 'alternative' = 'New-Item' | |
| } | |
| 'diff' = @{ | |
| 'alternative' = 'Compare-Object' | |
| } | |
| 'jq' = @{ | |
| 'alternative' = 'ConvertFrom-Json' | |
| } | |
| 'xargs' = @{ | |
| 'alternative' = 'ForEach-Object' | |
| } | |
| 'tee' = @{ | |
| 'alternative' = 'Tee-Object' | |
| } | |
| 'basename' = @{ | |
| 'alternative' = 'Split-Path -Leaf' | |
| } | |
| 'dirname' = @{ | |
| 'alternative' = 'Split-Path -Parent' | |
| } | |
| 'cut' = @{ | |
| 'alternative' = 'Select-Object / -split' | |
| } | |
| 'tr' = @{ | |
| 'alternative' = '-replace / -creplace' | |
| } | |
| 'man' = @{ | |
| 'alternative' = 'Get-Help' | |
| } | |
| 'type' = @{ | |
| 'alternative' = 'Get-Content (or use the Read tool)' | |
| } | |
| 'chmod' = @{ | |
| 'alternative' = 'Set-Acl' | |
| } | |
| 'tar' = @{ | |
| 'alternative' = 'Compress-Archive / Expand-Archive' | |
| } | |
| 'zip' = @{ | |
| 'alternative' = 'Compress-Archive' | |
| } | |
| 'unzip' = @{ | |
| 'alternative' = 'Expand-Archive' | |
| } | |
| 'export' = @{ | |
| 'alternative' = '$env:NAME = value' | |
| } | |
| 'source' = @{ | |
| 'alternative' = '. script.ps1' | |
| } | |
| 'read' = @{ | |
| 'alternative' = 'Read-Host' | |
| } | |
| 'test' = @{ | |
| 'alternative' = 'Test-Path / PowerShell comparison operators' | |
| } | |
| } | |
| function Get-PsAlternative { | |
| param([string]$Command) | |
| if ($map.ContainsKey($Command)) { | |
| return $map[$Command] | |
| } | |
| } | |
| function Invoke-BashTracker { | |
| param([object]$Hook, [string]$TallyPath) | |
| $command = $Hook.tool_input.command | |
| if (-not $command) { return } | |
| $base = Get-ClaudeBashBaseCommand $command | |
| if (-not $base) { return } | |
| Update-BashTally -Command $base -TallyPath $TallyPath | Out-Null | |
| $alt = Get-PsAlternative $base | |
| if ($alt) { | |
| $alternative = $alt.alternative | |
| if ($alt.block -or $alt.blocker) { | |
| Write-ClaudeHookDeny -Reason "Sir, this is a PowerShell. Use the PowerShell() tool. Alternative: $alternative" | |
| } else { | |
| Write-ClaudeHookResponse -SystemMessage "Sir, this is a PowerShell." -HookSpecificOutput @{ | |
| additionalContext = "Prefer the PowerShell() tool with native PS cmdlets. PowerShell alternative: $alternative" | |
| } | |
| } | |
| } | |
| } | |
| if ($MyInvocation.InvocationName -ne '.') { | |
| $hook = Read-ClaudeHookInput -Quiet | |
| if ($hook) { | |
| Invoke-BashTracker -Hook $hook -TallyPath "$env:USERPROFILE\.claude\hooks\bash-tally.json" | |
| } | |
| exit 0 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| BeforeAll { | |
| . "$PSScriptRoot\track-bash-usage.ps1" | |
| } | |
| Describe 'Update-BashTally' { | |
| BeforeEach { | |
| $testTallyPath = Join-Path $TestDrive "bash-tally-$(New-Guid).json" | |
| } | |
| It 'creates the tally file on first call' { | |
| Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| $testTallyPath | Should -Exist | |
| } | |
| It 'starts the count at 1' { | |
| Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| $tally = Get-Content $testTallyPath -Raw | ConvertFrom-Json | |
| $tally.grep | Should -Be 1 | |
| } | |
| It 'returns the new count' { | |
| $count = Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| $count | Should -Be 1 | |
| } | |
| It 'increments an existing count' { | |
| Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| $tally = Get-Content $testTallyPath -Raw | ConvertFrom-Json | |
| $tally.grep | Should -Be 2 | |
| } | |
| It 'tracks multiple commands independently' { | |
| Update-BashTally -Command 'grep' -TallyPath $testTallyPath | |
| Update-BashTally -Command 'find' -TallyPath $testTallyPath | |
| $tally = Get-Content $testTallyPath -Raw | ConvertFrom-Json | |
| $tally.grep | Should -Be 1 | |
| $tally.find | Should -Be 1 | |
| } | |
| It 'does not throw when the tally path is unwritable' { | |
| { Update-BashTally -Command 'grep' -TallyPath 'Z:\nonexistent\path\tally.json' } | Should -Not -Throw | |
| } | |
| } | |
| Describe 'Get-PsAlternative' { | |
| It 'returns a non-empty string for grep' { | |
| Get-PsAlternative 'grep' | Should -Not -BeNullOrEmpty | |
| } | |
| It 'returns a non-empty string for curl' { | |
| Get-PsAlternative 'curl' | Should -Not -BeNullOrEmpty | |
| } | |
| It 'returns $null for git' { | |
| Get-PsAlternative 'git' | Should -BeNullOrEmpty | |
| } | |
| It 'returns $null for npm' { | |
| Get-PsAlternative 'npm' | Should -BeNullOrEmpty | |
| } | |
| It 'returns $null for node' { | |
| Get-PsAlternative 'node' | Should -BeNullOrEmpty | |
| } | |
| } | |
| Describe 'Invoke-BashTracker' { | |
| BeforeEach { | |
| $testTallyPath = Join-Path $TestDrive "bash-tally-$(New-Guid).json" | |
| } | |
| It 'emits a deny decision for a blocked command' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":"grep -r foo ."}}' | |
| $result = Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | ConvertFrom-Json | |
| $result.hookSpecificOutput.permissionDecision | Should -Be 'deny' | |
| $result.hookSpecificOutput.permissionDecisionReason | Should -Match 'Select-String' | |
| } | |
| It 'updates the tally for a blocked command' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":"grep -r foo ."}}' | |
| Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | |
| $tally = Get-Content $testTallyPath -Raw | ConvertFrom-Json | |
| $tally.grep | Should -Be 1 | |
| } | |
| It 'emits a systemMessage for a non-blocked command with an alternative' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":"curl https://example.com"}}' | |
| $result = Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | ConvertFrom-Json | |
| $result.systemMessage | Should -Be 'Sir, this is a PowerShell.' | |
| $result.hookSpecificOutput.additionalContext | Should -Match 'Invoke-RestMethod' | |
| } | |
| It 'produces no output for an unknown command' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":"git status"}}' | |
| $result = Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | |
| $result | Should -BeNullOrEmpty | |
| } | |
| It 'still updates the tally for an unknown command' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":"git status"}}' | |
| Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | |
| $tally = Get-Content $testTallyPath -Raw | ConvertFrom-Json | |
| $tally.git | Should -Be 1 | |
| } | |
| It 'does not throw on an empty command string' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":""}}' | |
| { Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath } | Should -Not -Throw | |
| } | |
| It 'produces no output for an empty command string' { | |
| $hook = Read-ClaudeHookInput -InputString '{"tool_input":{"command":""}}' | |
| $result = Invoke-BashTracker -Hook $hook -TallyPath $testTallyPath | |
| $result | Should -BeNullOrEmpty | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment