fix(install): exchange install option content through UTF-8 files

Windows PowerShell pipes re-encode text by console code page: under code page
65001 a BOM broke the config.toml merge, and otherwise non-ASCII characters
became question marks. Pass managed and merged content through files, and
decode piped input as utf-8-sig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian lechnerandClaude Opus 5.5 committed 2026-10-01 10:23:14 +02:00
1 parent bed90fee2d
commit 857720bcfd
3 files changed
+51 -12

No files matched your search

+12 -6
View File
@@ -173,18 +173,24 @@ if __name__ == '__main__':
parser.add_argument('--current', type=Path) parser.add_argument('--current', type=Path)
parser.add_argument('--flashbang', choices=('true', 'false')) parser.add_argument('--flashbang', choices=('true', 'false'))
parser.add_argument('--statusline', choices=('true', 'false'), default='true') parser.add_argument('--statusline', choices=('true', 'false'), default='true')
parser.add_argument('--managed', type=Path)
parser.add_argument('--output', type=Path)
args = parser.parse_args() args = parser.parse_args()
try: try:
sys.stdout.reconfigure(encoding='utf-8')
if args.action == 'filter': if args.action == 'filter':
print(filter_options(args.path, args.flashbang == 'true', args.statusline == 'true'), end='') result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true')
else: else:
managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig')
current = args.current.read_text(encoding='utf-8-sig')
if args.action == 'merge-json': if args.action == 'merge-json':
current = json.loads(args.current.read_text(encoding='utf-8-sig')) result = json.dumps(merge_json(json.loads(current), json.loads(managed)), indent=2, ensure_ascii=False) + '\n'
managed = json.load(sys.stdin)
print(json.dumps(merge_json(current, managed), indent=2, ensure_ascii=False) + '\n', end='')
else: else:
print(merge_toml(args.current.read_text(encoding='utf-8-sig'), sys.stdin.read(), args.statusline == 'true'), end='') result = merge_toml(current, managed, args.statusline == 'true')
if args.output:
args.output.write_text(result, encoding='utf-8', newline='')
else:
sys.stdout.reconfigure(encoding='utf-8')
print(result, end='')
except (OSError, ValueError, KeyError) as error: except (OSError, ValueError, KeyError) as error:
print(f'Install options: {error}', file=sys.stderr) print(f'Install options: {error}', file=sys.stderr)
sys.exit(1) sys.exit(1)
+23 -6
View File
@@ -86,6 +86,26 @@ function Write-ManagedManifest {
[System.IO.File]::WriteAllText($ManifestPath, $content, (New-Object System.Text.UTF8Encoding($false))) [System.IO.File]::WriteAllText($ManifestPath, $content, (New-Object System.Text.UTF8Encoding($false)))
} }
function Invoke-InstallOptions {
param([Parameter(Mandatory=$true)][string[]]$Arguments, [byte[]]$Managed, [Parameter(Mandatory=$true)][string]$ErrorMessage)
# Exchange content through UTF-8 files: Windows PowerShell pipes re-encode text by console code page.
$outputPath = [System.IO.Path]::GetTempFileName()
$managedPath = $null
try {
if ($null -ne $Managed) {
$managedPath = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllBytes($managedPath, $Managed)
$Arguments += @('--managed', $managedPath)
}
& python (Join-Path $PSScriptRoot 'install-options.py') @Arguments --output $outputPath
if ($LASTEXITCODE -ne 0) { throw $ErrorMessage }
return [System.IO.File]::ReadAllText($outputPath, (New-Object System.Text.UTF8Encoding($false)))
} finally {
Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue
if ($managedPath) { Remove-Item -LiteralPath $managedPath -Force -ErrorAction SilentlyContinue }
}
}
function Sync-ManagedDestination { function Sync-ManagedDestination {
<# <#
.SYNOPSIS .SYNOPSIS
@@ -135,8 +155,7 @@ function Sync-ManagedDestination {
if ($filterOptions) { if ($filterOptions) {
$flashbangOption = if ($FlashbangEnabled) { 'true' } else { 'false' } $flashbangOption = if ($FlashbangEnabled) { 'true' } else { 'false' }
$statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' } $statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' }
$rawContent = (& python (Join-Path $PSScriptRoot 'install-options.py') filter --path $_.FullName --flashbang $flashbangOption --statusline $statusLineOption | Out-String) $rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption) -ErrorMessage 'Could not configure install options.'
if ($LASTEXITCODE -ne 0) { throw 'Could not configure install options.' }
} }
$needsSubstitution = $filterOptions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__') $needsSubstitution = $filterOptions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__')
if ($needsSubstitution) { if ($needsSubstitution) {
@@ -147,13 +166,11 @@ function Sync-ManagedDestination {
} }
if ($relative -eq 'config.toml' -and (Test-Path -LiteralPath $target)) { if ($relative -eq 'config.toml' -and (Test-Path -LiteralPath $target)) {
$statusLineFlag = if ($StatusLineEnabled) { 'true' } else { 'false' } $statusLineFlag = if ($StatusLineEnabled) { 'true' } else { 'false' }
$content = [System.Text.Encoding]::UTF8.GetString($newBytes) | & python (Join-Path $PSScriptRoot 'install-options.py') merge-toml --current $target --statusline $statusLineFlag | Out-String $content = Invoke-InstallOptions -Arguments @('merge-toml', '--current', $target, '--statusline', $statusLineFlag) -Managed $newBytes -ErrorMessage 'Could not merge managed config.toml keys.'
if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed config.toml keys.' }
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($content) $newBytes = [System.Text.Encoding]::UTF8.GetBytes($content)
} }
if ($relative -eq 'settings.json' -and (Test-Path -LiteralPath $target)) { if ($relative -eq 'settings.json' -and (Test-Path -LiteralPath $target)) {
$finalContent = [System.Text.Encoding]::UTF8.GetString($newBytes) | & python (Join-Path $PSScriptRoot 'install-options.py') merge-json --current $target | Out-String $finalContent = Invoke-InstallOptions -Arguments @('merge-json', '--current', $target) -Managed $newBytes -ErrorMessage 'Could not merge managed settings.json keys.'
if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed settings.json keys.' }
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent) $newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
} }
$newHash = Get-Sha256HashOfBytes $newBytes $newHash = Get-Sha256HashOfBytes $newBytes
+16
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess import subprocess
import sys
import tempfile import tempfile
import tomllib import tomllib
import unittest import unittest
@@ -44,6 +45,21 @@ class InstallOptionsTests(unittest.TestCase):
self.assertEqual(result['mcp_servers'], {'demo': {'command': 'demo', 'env': {'TOKEN_NAME': 'x'}}}) self.assertEqual(result['mcp_servers'], {'demo': {'command': 'demo', 'env': {'TOKEN_NAME': 'x'}}})
self.assertEqual(result['model_providers'], {'custom': {'name': 'Custom', 'base_url': 'https://example.invalid'}}) self.assertEqual(result['model_providers'], {'custom': {'name': 'Custom', 'base_url': 'https://example.invalid'}})
def test_merge_cli_accepts_bom_input_and_preserves_non_ascii(self):
with tempfile.TemporaryDirectory() as directory:
current = Path(directory) / 'config.toml'
current.write_text('# Größe\n[mcp_servers.demo]\ncommand = "ä"\n', encoding='utf-8')
output = Path(directory) / 'out.toml'
script = str(ROOT / 'scripts/lib/install-options.py')
managed = 'model = "new"\n'.encode('utf-8')
piped = subprocess.run([sys.executable, script, 'merge-toml', '--current', str(current), '--output', str(output)], input=managed)
self.assertEqual(piped.returncode, 0)
self.assertEqual(tomllib.loads(output.read_text(encoding='utf-8'))['mcp_servers']['demo']['command'], 'ä')
managed_path = Path(directory) / 'managed.toml'
managed_path.write_bytes(managed)
subprocess.run([sys.executable, script, 'merge-toml', '--current', str(current), '--managed', str(managed_path), '--output', str(output)], check=True)
self.assertIn('# Größe', output.read_text(encoding='utf-8'))
def test_filter_preserves_other_claude_hooks_and_status(self): def test_filter_preserves_other_claude_hooks_and_status(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'settings.json' path = Path(directory) / 'settings.json'