mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-22 18:13:41 +08:00
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:
78
docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md
Normal file
78
docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md
Normal 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)
|
||||
187
docs/fixes/patch_settings_cl_v2_simple.ps1
Normal file
187
docs/fixes/patch_settings_cl_v2_simple.ps1
Normal 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
|
||||
Reference in New Issue
Block a user