Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save HeyItsGilbert/095f1f7dccbd59d8be677baa9c81a0e9 to your computer and use it in GitHub Desktop.

Select an option

Save HeyItsGilbert/095f1f7dccbd59d8be677baa9c81a0e9 to your computer and use it in GitHub Desktop.
PowerShell optimizations
#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
#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
}
}
}
#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
}
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