diff --git a/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md b/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md new file mode 100644 index 00000000..4a3e8cdc --- /dev/null +++ b/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md @@ -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//.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre +bash "C:/Users//.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-` 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) diff --git a/docs/fixes/patch_settings_cl_v2_simple.ps1 b/docs/fixes/patch_settings_cl_v2_simple.ps1 new file mode 100644 index 00000000..86d30b5b --- /dev/null +++ b/docs/fixes/patch_settings_cl_v2_simple.ps1 @@ -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