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.
This commit is contained in:
suusuu0927 2026-04-22 06:55:29 +09:00
parent c32f0fffb1
commit b6bce947f1
2 changed files with 116 additions and 9 deletions

View File

@ -46,8 +46,21 @@ pwsh -File docs/fixes/install_hook_wrapper.ps1
The script backs up `settings.local.json` 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 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

View File

@ -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"