everything-claude-code/docs/fixes/patch_settings_cl_v2_simple.ps1
suusuu0927 1ebf45c533 fix(hooks): rewrite patch_settings_cl_v2_simple.ps1 to avoid argv-dup bug
- Use PATH-resolved `bash` as first token instead of quoted `.exe` path
  so Claude Code v2.1.116 argv duplication does not feed a binary to
  bash as its $0 (repro: exit 126 "cannot execute binary file").
- Point the command at `observe-wrapper.sh` and pass distinct `pre` /
  `post` positional arguments so PreToolUse and PostToolUse are
  registered as separate entries.
- Normalize the wrapper path to forward slashes before embedding in the
  hook command to avoid MSYS backslash surprises.
- Write UTF-8 (no BOM) with CRLF normalized to LF so downstream JSON
  parsers never see mixed line endings.
- Preserve existing hooks (legacy `observe.sh`, third-party entries)
  by appending only when the canonical command string is not already
  registered. Re-runs are idempotent ([SKIP] both phases).
- Keep the script compatible with Windows PowerShell 5.1: fall back to
  a manual PSCustomObject → Hashtable conversion when
  `ConvertFrom-Json -AsHashtable` is unavailable, and materialize hook
  arrays as `System.Collections.ArrayList` so single-element arrays
  survive PS 5.1 `ConvertTo-Json` serialization.

Companion to PR #1524 (settings.local.json shape fix) and PR #1540
(install_hook_wrapper.ps1 argv-dup fix).
2026-04-22 06:41:12 +09:00

188 lines
7.3 KiB
PowerShell

# Simple patcher for settings.local.json - CL v2 hooks (argv-dup safe)
#
# No Japanese literals - keeps the file ASCII-only so PowerShell parses it
# regardless of the active code page.
#
# argv-dup bug workaround (Claude Code v2.1.116):
# - Use PATH-resolved `bash` (no quoted .exe) as the first argv token.
# - Point the hook at observe-wrapper.sh (not observe.sh).
# - Pass `pre` / `post` as explicit positional arguments so PreToolUse and
# PostToolUse are registered as distinct commands.
# - Normalize the wrapper path to forward slashes to keep MSYS/Git Bash
# happy and write the JSON with LF endings only.
#
# References:
# - PR #1524 (settings.local.json argv-dup fix)
# - PR #1540 (install_hook_wrapper.ps1 argv-dup fix)
$ErrorActionPreference = "Stop"
$SettingsPath = "$env:USERPROFILE\.claude\settings.local.json"
$WrapperDst = "$env:USERPROFILE\.claude\skills\continuous-learning\hooks\observe-wrapper.sh"
$BashExe = "bash"
# Normalize wrapper path to forward slashes and build distinct pre/post
# commands. Quoting keeps spaces in the path safe.
$wrapperPath = $WrapperDst -replace '\\','/'
$preCmd = $BashExe + ' "' + $wrapperPath + '" pre'
$postCmd = $BashExe + ' "' + $wrapperPath + '" post'
Write-Host "=== CL v2 Simple Patcher (argv-dup safe) ===" -ForegroundColor Cyan
Write-Host "Target : $SettingsPath"
Write-Host "Wrapper : $wrapperPath"
Write-Host "Pre command : $preCmd"
Write-Host "Post command: $postCmd"
# Ensure parent dir exists
$parent = Split-Path $SettingsPath
if (-not (Test-Path $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
function New-HookEntry {
param([string]$Command)
# Inner `hooks` uses ArrayList so a single-element list does not get
# collapsed into an object when PS 5.1 ConvertTo-Json serializes the
# enclosing Hashtable.
$inner = [System.Collections.ArrayList]::new()
$null = $inner.Add(@{ type = "command"; command = $Command })
return @{
matcher = "*"
hooks = $inner
}
}
# Convert a PSCustomObject tree (as returned by ConvertFrom-Json on PS 5.1)
# into nested Hashtables/Arrays so the merge logic below works uniformly.
# PS 7+ gets the same shape via `ConvertFrom-Json -AsHashtable` directly.
function ConvertTo-HashtableRecursive {
param($InputObject)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
$result = @{}
foreach ($key in $InputObject.Keys) {
$result[$key] = ConvertTo-HashtableRecursive -InputObject $InputObject[$key]
}
return $result
}
if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
$result = @{}
foreach ($prop in $InputObject.PSObject.Properties) {
$result[$prop.Name] = ConvertTo-HashtableRecursive -InputObject $prop.Value
}
return $result
}
if ($InputObject -is [System.Collections.IList] -or $InputObject -is [System.Array]) {
# Use ArrayList so PS 5.1 ConvertTo-Json preserves single-element
# arrays instead of collapsing them into objects. Plain Object[]
# suffers from that collapse when embedded in a Hashtable value.
$result = [System.Collections.ArrayList]::new()
foreach ($item in $InputObject) {
$null = $result.Add((ConvertTo-HashtableRecursive -InputObject $item))
}
return ,$result
}
return $InputObject
}
function Read-SettingsAsHashtable {
param([string]$Path)
$raw = Get-Content -Raw -Path $Path -Encoding UTF8
if ([string]::IsNullOrWhiteSpace($raw)) { return @{} }
# Prefer `-AsHashtable` (PS 7+); fall back to manual conversion on PS 5.1
# where that parameter does not exist.
try {
return ($raw | ConvertFrom-Json -AsHashtable)
} catch {
$obj = $raw | ConvertFrom-Json
return (ConvertTo-HashtableRecursive -InputObject $obj)
}
}
$preEntry = New-HookEntry -Command $preCmd
$postEntry = New-HookEntry -Command $postCmd
if (Test-Path $SettingsPath) {
$backup = "$SettingsPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $SettingsPath $backup -Force
Write-Host "[BACKUP] $backup" -ForegroundColor Yellow
try {
$existing = Read-SettingsAsHashtable -Path $SettingsPath
} catch {
Write-Host "[WARN] Failed to parse existing JSON, will overwrite (backup preserved)" -ForegroundColor Yellow
$existing = @{}
}
if ($null -eq $existing) { $existing = @{} }
if (-not $existing.ContainsKey("hooks")) {
$existing["hooks"] = @{}
}
# Normalize the two hook buckets into ArrayList so both existing and newly
# added entries survive PS 5.1 ConvertTo-Json array collapsing.
foreach ($key in @("PreToolUse", "PostToolUse")) {
if (-not $existing.hooks.ContainsKey($key)) {
$existing.hooks[$key] = [System.Collections.ArrayList]::new()
} elseif (-not ($existing.hooks[$key] -is [System.Collections.ArrayList])) {
$list = [System.Collections.ArrayList]::new()
foreach ($item in @($existing.hooks[$key])) { $null = $list.Add($item) }
$existing.hooks[$key] = $list
}
# Each entry's inner `hooks` array needs the same treatment so legacy
# single-element arrays do not serialize as bare objects.
foreach ($entry in $existing.hooks[$key]) {
if ($entry -is [System.Collections.IDictionary] -and $entry.ContainsKey("hooks") -and
-not ($entry["hooks"] -is [System.Collections.ArrayList])) {
$innerList = [System.Collections.ArrayList]::new()
foreach ($item in @($entry["hooks"])) { $null = $innerList.Add($item) }
$entry["hooks"] = $innerList
}
}
}
# Duplicate check uses the exact command string so legacy observe.sh
# entries are left in place unless re-run manually removes them.
$hasPre = $false
foreach ($e in $existing.hooks.PreToolUse) {
foreach ($h in @($e.hooks)) { if ($h.command -eq $preCmd) { $hasPre = $true } }
}
$hasPost = $false
foreach ($e in $existing.hooks.PostToolUse) {
foreach ($h in @($e.hooks)) { if ($h.command -eq $postCmd) { $hasPost = $true } }
}
if (-not $hasPre) {
$null = $existing.hooks.PreToolUse.Add($preEntry)
Write-Host "[ADD] PreToolUse" -ForegroundColor Green
} else {
Write-Host "[SKIP] PreToolUse already registered" -ForegroundColor Gray
}
if (-not $hasPost) {
$null = $existing.hooks.PostToolUse.Add($postEntry)
Write-Host "[ADD] PostToolUse" -ForegroundColor Green
} else {
Write-Host "[SKIP] PostToolUse already registered" -ForegroundColor Gray
}
$json = $existing | ConvertTo-Json -Depth 20
} else {
Write-Host "[CREATE] new settings.local.json" -ForegroundColor Green
$newSettings = @{
hooks = @{
PreToolUse = @($preEntry)
PostToolUse = @($postEntry)
}
}
$json = $newSettings | ConvertTo-Json -Depth 20
}
# Write UTF-8 no BOM and normalize CRLF -> LF so hook parsers never see
# mixed line endings.
$jsonLf = $json -replace "`r`n","`n"
$utf8 = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($SettingsPath, $jsonLf, $utf8)
Write-Host ""
Write-Host "=== Patch SUCCESS ===" -ForegroundColor Green
Write-Host ""
Get-Content -Path $SettingsPath -Encoding UTF8