From c32f0fffb17d43eeea6ce170c4e375177057a9ff Mon Sep 17 00:00:00 2001 From: suusuu0927 Date: Wed, 22 Apr 2026 06:19:15 +0900 Subject: [PATCH 1/2] fix(hooks): rewrite install_hook_wrapper.ps1 to avoid argv-dup bug Under Claude Code v2.1.116 the first argv token of a hook command is duplicated. When the token is a quoted Windows .exe path, bash.exe is re-invoked with itself as script (exit 126). PR #1524 fixed the shape of settings.local.json; this script keeps the installer consistent so re-running it does not regenerate the broken form. Changes: - First token is now PATH-resolved `bash` instead of the quoted bash.exe - Wrapper path is normalized to forward slashes for MSYS safety - PreToolUse and PostToolUse get distinct pre/post positional arguments - JSON output is written with LF endings (no mixed CRLF/LF) Companion doc: docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md --- .../INSTALL-HOOK-WRAPPER-FIX-20260422.md | 53 ++++++++++++++ docs/fixes/install_hook_wrapper.ps1 | 73 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md create mode 100644 docs/fixes/install_hook_wrapper.ps1 diff --git a/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md b/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md new file mode 100644 index 00000000..8b4203b8 --- /dev/null +++ b/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md @@ -0,0 +1,53 @@ +# install_hook_wrapper.ps1 argv-dup bug workaround (2026-04-22) + +## Summary + +`docs/fixes/install_hook_wrapper.ps1` is the PowerShell helper that copies +`observe-wrapper.sh` into `~/.claude/skills/continuous-learning/hooks/` and +rewrites `~/.claude/settings.local.json` so the observer hook points at it. + +The previous version produced a hook command of the form: + +``` +"C:\Program Files\Git\bin\bash.exe" "C:\Users\...\observe-wrapper.sh" +``` + +Under Claude Code v2.1.116 the first argv token is duplicated. When that token +is a quoted Windows executable path, `bash.exe` is re-invoked with itself as +its `$0`, which fails with `cannot execute binary file` (exit 126). PR #1524 +documents the root cause; this script is a companion that keeps the installer +in sync with the fixed `settings.local.json` layout. + +## What the fix does + +- First token is now the PATH-resolved `bash` (no quoted `.exe` path), so the + argv-dup bug no longer passes a binary as a script. +- 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, matching the shape the wrapper expects. +- The settings file is written with LF line endings so downstream JSON parsers + never see mixed CRLF/LF output from `ConvertTo-Json`. + +## 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 +# Place observe-wrapper.sh next to this script, then: +pwsh -File docs/fixes/install_hook_wrapper.ps1 +``` + +The script backs up `settings.local.json` to +`settings.local.json.bak-` before writing. + +## Related + +- PR #1524 — settings.local.json shape fix (same argv-dup root cause) +- PR #1511 — skip `AppInstallerPythonRedirector.exe` in observer python resolution +- PR #1539 — locale-independent `detect-project.sh` diff --git a/docs/fixes/install_hook_wrapper.ps1 b/docs/fixes/install_hook_wrapper.ps1 new file mode 100644 index 00000000..1966740e --- /dev/null +++ b/docs/fixes/install_hook_wrapper.ps1 @@ -0,0 +1,73 @@ +# Install observe-wrapper.sh + rewrite settings.local.json to use it +# No Japanese literals - uses $PSScriptRoot instead +# argv-dup bug workaround: use `bash` (PATH-resolved) as first token and +# normalize wrapper path to forward slashes. See PR #1524. +$ErrorActionPreference = "Stop" + +$SkillHooks = "$env:USERPROFILE\.claude\skills\continuous-learning\hooks" +$WrapperSrc = Join-Path $PSScriptRoot "observe-wrapper.sh" +$WrapperDst = "$SkillHooks\observe-wrapper.sh" +$SettingsPath = "$env:USERPROFILE\.claude\settings.local.json" +# Use PATH-resolved `bash` to avoid Claude Code v2.1.116 argv-dup bug that +# double-passes the first token when the quoted path is a Windows .exe. +$BashExe = "bash" + +Write-Host "=== Install Hook Wrapper ===" -ForegroundColor Cyan +Write-Host "ScriptRoot: $PSScriptRoot" +Write-Host "WrapperSrc: $WrapperSrc" + +if (-not (Test-Path $WrapperSrc)) { + Write-Host "[ERROR] Source not found: $WrapperSrc" -ForegroundColor Red + exit 1 +} + +# 1) Copy wrapper + LF normalization +Write-Host "[1/4] Copy wrapper to $WrapperDst" -ForegroundColor Yellow +$content = Get-Content -Raw -Path $WrapperSrc +$contentLf = $content -replace "`r`n","`n" +$utf8 = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText($WrapperDst, $contentLf, $utf8) +Write-Host " [OK] wrapper installed with LF endings" -ForegroundColor Green + +# 2) Backup settings +Write-Host "[2/4] Backup settings.local.json" -ForegroundColor Yellow +$backup = "$SettingsPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" +Copy-Item $SettingsPath $backup -Force +Write-Host " [OK] $backup" -ForegroundColor Green + +# 3) Rewrite command path in settings.local.json +Write-Host "[3/4] Rewrite hook command to wrapper" -ForegroundColor Yellow +$settings = Get-Content -Raw -Path $SettingsPath -Encoding UTF8 | ConvertFrom-Json -AsHashtable + +# Normalize wrapper path to forward slashes so bash (MSYS/Git Bash) does not +# mangle backslashes; quoting keeps spaces safe. +$wrapperPath = $WrapperDst -replace '\\','/' +$preCmd = $BashExe + ' "' + $wrapperPath + '" pre' +$postCmd = $BashExe + ' "' + $wrapperPath + '" post' + +foreach ($entry in $settings.hooks.PreToolUse) { + foreach ($h in $entry.hooks) { + $h.command = $preCmd + } +} +foreach ($entry in $settings.hooks.PostToolUse) { + foreach ($h in $entry.hooks) { + $h.command = $postCmd + } +} + +$json = $settings | ConvertTo-Json -Depth 20 +# Normalize CRLF -> LF so hook parsers never see mixed line endings. +$jsonLf = $json -replace "`r`n","`n" +[System.IO.File]::WriteAllText($SettingsPath, $jsonLf, $utf8) +Write-Host " [OK] command updated" -ForegroundColor Green +Write-Host " PreToolUse command: $preCmd" +Write-Host " PostToolUse command: $postCmd" + +# 4) Verify +Write-Host "[4/4] Verify" -ForegroundColor Yellow +Get-Content $SettingsPath | Select-String "command" + +Write-Host "" +Write-Host "=== DONE ===" -ForegroundColor Green +Write-Host "Next: Launch Claude CLI and run any command to trigger observations.jsonl" From b6bce947f18c2a550d4fefe147179f6aa60fd671 Mon Sep 17 00:00:00 2001 From: suusuu0927 Date: Wed, 22 Apr 2026 06:55:29 +0900 Subject: [PATCH 2/2] fix(hooks): add Windows PowerShell 5.1 compatibility to install_hook_wrapper.ps1 `ConvertFrom-Json -AsHashtable` is PowerShell 7+ only, and the Windows 11 reference machine used to validate this PR ships with Windows PowerShell 5.1 only (no `pwsh` on PATH). Without this follow-up, running the installer on stock Windows fails at the parse step and leaves the installation half-applied. - Fall back to a manual `PSCustomObject` -> `Hashtable` conversion when `-AsHashtable` raises, so the script parses the existing settings.local.json on both PS 5.1 and PS 7+. - Normalize both hook buckets (`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays as `System.Collections.ArrayList` before serialization. PS 5.1 `ConvertTo-Json` otherwise collapses single-element arrays into bare objects, which breaks the canonical PR #1524 shape. - Create the `skills/continuous-learning/hooks` destination directory when it does not exist yet, and emit a clearer error if settings.local.json is missing entirely. - Update `INSTALL-HOOK-WRAPPER-FIX-20260422.md` to document the PS 5.1 compatibility guarantee and to cross-link PR #1542 (companion simple patcher). Verified on Windows 11 / Windows PowerShell 5.1.26100.8115 by running `powershell -NoProfile -ExecutionPolicy Bypass -File docs/fixes/install_hook_wrapper.ps1` against a sandbox `$env:USERPROFILE` and against the real settings.local.json. Both produce the canonical PR #1524 shape with LF-only output. --- .../INSTALL-HOOK-WRAPPER-FIX-20260422.md | 13 ++ docs/fixes/install_hook_wrapper.ps1 | 112 ++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md b/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md index 8b4203b8..0572f85f 100644 --- a/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md +++ b/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md @@ -46,8 +46,21 @@ pwsh -File docs/fixes/install_hook_wrapper.ps1 The script backs up `settings.local.json` 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 by running `powershell -NoProfile -File +docs/fixes/install_hook_wrapper.ps1` on a Windows 11 machine with only +Windows PowerShell 5.1 installed (no `pwsh`). + ## Related - PR #1524 — settings.local.json shape fix (same argv-dup root cause) - PR #1511 — skip `AppInstallerPythonRedirector.exe` in observer python resolution - PR #1539 — locale-independent `detect-project.sh` +- PR #1542 — `patch_settings_cl_v2_simple.ps1` companion fix diff --git a/docs/fixes/install_hook_wrapper.ps1 b/docs/fixes/install_hook_wrapper.ps1 index 1966740e..01809708 100644 --- a/docs/fixes/install_hook_wrapper.ps1 +++ b/docs/fixes/install_hook_wrapper.ps1 @@ -2,6 +2,15 @@ # No Japanese literals - uses $PSScriptRoot instead # argv-dup bug workaround: use `bash` (PATH-resolved) as first token and # normalize wrapper path to forward slashes. See PR #1524. +# +# PowerShell 5.1 compatibility: +# - `ConvertFrom-Json -AsHashtable` is PS 7+ only; fall back to a manual +# PSCustomObject -> Hashtable conversion on Windows PowerShell 5.1. +# - PS 5.1 `ConvertTo-Json` collapses single-element arrays inside +# Hashtables into bare objects. Normalize the hook buckets +# (PreToolUse / PostToolUse) and their inner `hooks` arrays as +# `System.Collections.ArrayList` before serialization to preserve +# array shape. $ErrorActionPreference = "Stop" $SkillHooks = "$env:USERPROFILE\.claude\skills\continuous-learning\hooks" @@ -21,7 +30,65 @@ if (-not (Test-Path $WrapperSrc)) { exit 1 } -# 1) Copy wrapper + LF normalization +# Ensure the hook destination directory exists (fresh installs have no +# skills/continuous-learning/hooks tree yet). +$dstDir = Split-Path $WrapperDst +if (-not (Test-Path $dstDir)) { + New-Item -ItemType Directory -Path $dstDir -Force | Out-Null +} + +# --- Helpers ------------------------------------------------------------ + +# Convert a PSCustomObject tree (as returned by ConvertFrom-Json on PS 5.1) +# into nested Hashtables/ArrayLists so the merge logic below works uniformly +# and so ConvertTo-Json preserves single-element arrays on PS 5.1. +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]) { + $list = [System.Collections.ArrayList]::new() + foreach ($item in $InputObject) { + $null = $list.Add((ConvertTo-HashtableRecursive -InputObject $item)) + } + return ,$list + } + return $InputObject +} + +function Read-SettingsAsHashtable { + param([string]$Path) + $raw = Get-Content -Raw -Path $Path -Encoding UTF8 + if ([string]::IsNullOrWhiteSpace($raw)) { return @{} } + try { + return ($raw | ConvertFrom-Json -AsHashtable) + } catch { + $obj = $raw | ConvertFrom-Json + return (ConvertTo-HashtableRecursive -InputObject $obj) + } +} + +function ConvertTo-ArrayList { + param($Value) + $list = [System.Collections.ArrayList]::new() + foreach ($item in @($Value)) { $null = $list.Add($item) } + return ,$list +} + +# --- 1) Copy wrapper + LF normalization --------------------------------- Write-Host "[1/4] Copy wrapper to $WrapperDst" -ForegroundColor Yellow $content = Get-Content -Raw -Path $WrapperSrc $contentLf = $content -replace "`r`n","`n" @@ -29,15 +96,20 @@ $utf8 = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($WrapperDst, $contentLf, $utf8) Write-Host " [OK] wrapper installed with LF endings" -ForegroundColor Green -# 2) Backup settings +# --- 2) Backup settings ------------------------------------------------- Write-Host "[2/4] Backup settings.local.json" -ForegroundColor Yellow +if (-not (Test-Path $SettingsPath)) { + Write-Host "[ERROR] Settings file not found: $SettingsPath" -ForegroundColor Red + Write-Host " Run patch_settings_cl_v2_simple.ps1 first to bootstrap the file." -ForegroundColor Yellow + exit 1 +} $backup = "$SettingsPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item $SettingsPath $backup -Force Write-Host " [OK] $backup" -ForegroundColor Green -# 3) Rewrite command path in settings.local.json +# --- 3) Rewrite command path in settings.local.json --------------------- Write-Host "[3/4] Rewrite hook command to wrapper" -ForegroundColor Yellow -$settings = Get-Content -Raw -Path $SettingsPath -Encoding UTF8 | ConvertFrom-Json -AsHashtable +$settings = Read-SettingsAsHashtable -Path $SettingsPath # Normalize wrapper path to forward slashes so bash (MSYS/Git Bash) does not # mangle backslashes; quoting keeps spaces safe. @@ -45,14 +117,36 @@ $wrapperPath = $WrapperDst -replace '\\','/' $preCmd = $BashExe + ' "' + $wrapperPath + '" pre' $postCmd = $BashExe + ' "' + $wrapperPath + '" post' +if (-not $settings.ContainsKey("hooks") -or $null -eq $settings["hooks"]) { + $settings["hooks"] = @{} +} +foreach ($key in @("PreToolUse", "PostToolUse")) { + if (-not $settings.hooks.ContainsKey($key) -or $null -eq $settings.hooks[$key]) { + $settings.hooks[$key] = [System.Collections.ArrayList]::new() + } elseif (-not ($settings.hooks[$key] -is [System.Collections.ArrayList])) { + $settings.hooks[$key] = (ConvertTo-ArrayList -Value $settings.hooks[$key]) + } + # Inner `hooks` arrays need the same ArrayList normalization to + # survive PS 5.1 ConvertTo-Json serialization. + foreach ($entry in $settings.hooks[$key]) { + if ($entry -is [System.Collections.IDictionary] -and $entry.ContainsKey("hooks") -and + -not ($entry["hooks"] -is [System.Collections.ArrayList])) { + $entry["hooks"] = (ConvertTo-ArrayList -Value $entry["hooks"]) + } + } +} + +# Point every existing hook command at the wrapper with the appropriate +# positional argument. The entry shape is preserved exactly; only the +# `command` field is rewritten. foreach ($entry in $settings.hooks.PreToolUse) { - foreach ($h in $entry.hooks) { - $h.command = $preCmd + foreach ($h in @($entry.hooks)) { + if ($h -is [System.Collections.IDictionary]) { $h["command"] = $preCmd } } } foreach ($entry in $settings.hooks.PostToolUse) { - foreach ($h in $entry.hooks) { - $h.command = $postCmd + foreach ($h in @($entry.hooks)) { + if ($h -is [System.Collections.IDictionary]) { $h["command"] = $postCmd } } } @@ -64,7 +158,7 @@ Write-Host " [OK] command updated" -ForegroundColor Green Write-Host " PreToolUse command: $preCmd" Write-Host " PostToolUse command: $postCmd" -# 4) Verify +# --- 4) Verify ---------------------------------------------------------- Write-Host "[4/4] Verify" -ForegroundColor Yellow Get-Content $SettingsPath | Select-String "command"