Merge pull request #1542 from suusuu0927/claude/patch-settings-simple-fix-20260422

fix(hooks): rewrite patch_settings_cl_v2_simple.ps1 to avoid argv-dup bug
This commit is contained in:
Affaan Mustafa
2026-04-21 18:18:47 -04:00
committed by GitHub
2 changed files with 265 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# patch_settings_cl_v2_simple.ps1 argv-dup bug workaround (2026-04-22)
## Summary
`docs/fixes/patch_settings_cl_v2_simple.ps1` is the minimal PowerShell
helper that patches `~/.claude/settings.local.json` so the observer hook
points at `observe-wrapper.sh`. It is the "simple" counterpart of
`docs/fixes/install_hook_wrapper.ps1` (PR #1540): it never copies the
wrapper script, it only rewrites the settings file.
The previous version of this helper registered the raw `observe.sh` path
as the hook command, shared a single command string across `PreToolUse`
and `PostToolUse`, and relied on `ConvertTo-Json` defaults that can emit
CRLF line endings. Under Claude Code v2.1.116 the first argv token is
duplicated, so the wrapper needs to be invoked with a specific shape and
the two hook phases need distinct entries.
## What the fix does
- First token is the PATH-resolved `bash` (no quoted `.exe` path), so the
argv-dup bug no longer passes a binary as a script. Matches PR #1524 and
PR #1540.
- The wrapper path is normalized to forward slashes before it is embedded
in the hook command, avoiding MSYS backslash handling surprises.
- `PreToolUse` and `PostToolUse` receive distinct commands with explicit
`pre` / `post` positional arguments.
- The settings file is written UTF-8 (no BOM) with CRLF normalized to LF
so downstream JSON parsers never see mixed line endings.
- Existing hooks (including legacy `observe.sh` entries and unrelated
third-party hooks) are preserved — the script only appends the new
wrapper entries when they are not already registered.
- Idempotent on re-runs: a second invocation recognizes the canonical
command strings and logs `[SKIP]` instead of duplicating entries.
## Resulting command shape
```
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post
```
## Usage
```powershell
pwsh -File docs/fixes/patch_settings_cl_v2_simple.ps1
# Windows PowerShell 5.1 is also supported:
powershell -NoProfile -ExecutionPolicy Bypass -File docs/fixes/patch_settings_cl_v2_simple.ps1
```
The script backs up the existing settings file to
`settings.local.json.bak-<timestamp>` before writing.
## PowerShell 5.1 compatibility
`ConvertFrom-Json -AsHashtable` is PowerShell 7+ only. The script tries
`-AsHashtable` first and falls back to a manual `PSCustomObject`
`Hashtable` conversion on Windows PowerShell 5.1. Both hook buckets
(`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays are
materialized as `System.Collections.ArrayList` before serialization, so
PS 5.1's `ConvertTo-Json` cannot collapse single-element arrays into bare
objects.
## Verified cases (dry-run)
1. Fresh install — no existing settings → creates canonical file.
2. Idempotent re-run — existing canonical file → `[SKIP]` both phases,
file contents unchanged apart from the pre-write backup.
3. Legacy `observe.sh` present → preserves the legacy entries and
appends the new `observe-wrapper.sh` entries alongside them.
All three cases produce LF-only output and match the shape registered by
PR #1524's manual fix to `settings.local.json`.
## Related
- PR #1524 — settings.local.json shape fix (same argv-dup root cause)
- PR #1539 — locale-independent `detect-project.sh`
- PR #1540`install_hook_wrapper.ps1` argv-dup fix (companion script)

View File

@@ -0,0 +1,187 @@
# 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