diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index 6b610cf..a6a757e 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -173,18 +173,24 @@ if __name__ == '__main__': parser.add_argument('--current', type=Path) parser.add_argument('--flashbang', choices=('true', 'false')) 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() try: - sys.stdout.reconfigure(encoding='utf-8') 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: + 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': - current = json.loads(args.current.read_text(encoding='utf-8-sig')) - managed = json.load(sys.stdin) - print(json.dumps(merge_json(current, managed), indent=2, ensure_ascii=False) + '\n', end='') + result = json.dumps(merge_json(json.loads(current), json.loads(managed)), indent=2, ensure_ascii=False) + '\n' 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: print(f'Install options: {error}', file=sys.stderr) sys.exit(1) diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index f042edd..ccc77dc 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -86,6 +86,26 @@ function Write-ManagedManifest { [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 { <# .SYNOPSIS @@ -135,8 +155,7 @@ function Sync-ManagedDestination { if ($filterOptions) { $flashbangOption = if ($FlashbangEnabled) { '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) - if ($LASTEXITCODE -ne 0) { throw 'Could not configure install options.' } + $rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption) -ErrorMessage '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__') if ($needsSubstitution) { @@ -147,13 +166,11 @@ function Sync-ManagedDestination { } if ($relative -eq 'config.toml' -and (Test-Path -LiteralPath $target)) { $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 - if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed config.toml keys.' } + $content = Invoke-InstallOptions -Arguments @('merge-toml', '--current', $target, '--statusline', $statusLineFlag) -Managed $newBytes -ErrorMessage 'Could not merge managed config.toml keys.' $newBytes = [System.Text.Encoding]::UTF8.GetBytes($content) } 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 - if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed settings.json keys.' } + $finalContent = Invoke-InstallOptions -Arguments @('merge-json', '--current', $target) -Managed $newBytes -ErrorMessage 'Could not merge managed settings.json keys.' $newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent) } $newHash = Get-Sha256HashOfBytes $newBytes diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index 0aa361a..d16c1fe 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -4,6 +4,7 @@ import os from pathlib import Path import shutil import subprocess +import sys import tempfile import tomllib 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['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): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'settings.json'